diff --git a/apps/backend/cmd/init/main.go b/apps/backend/cmd/init/main.go
index a1522d2..2cdda61 100644
--- a/apps/backend/cmd/init/main.go
+++ b/apps/backend/cmd/init/main.go
@@ -43,6 +43,7 @@ func main() {
},
"studio_outbox": {
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "updated_at", Value: 1}}, Options: options.Index().SetName("worker_outbox_status_updated")},
+ {Keys: bson.D{{Key: "steps.status", Value: 1}, {Key: "steps.scheduled_at", Value: 1}, {Key: "steps.lease_expires_at", Value: 1}}, Options: options.Index().SetName("claim_due_outbox_steps")},
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "updated_at", Value: -1}}, Options: options.Index().SetName("owner_outbox_updated")},
},
}
diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go
index 7d4b909..7e0b984 100644
--- a/apps/backend/cmd/worker/main.go
+++ b/apps/backend/cmd/worker/main.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
+ "errors"
"flag"
"fmt"
"os"
@@ -13,6 +14,7 @@ import (
"apps/backend/internal/config"
redislock "apps/backend/internal/lib/redislock"
+ "apps/backend/internal/lib/securelog"
"apps/backend/internal/module/ai"
appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase"
@@ -40,6 +42,7 @@ import (
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/core/logx"
+ "github.com/zeromicro/go-zero/core/stores/redis"
)
// Worker:demo + threads_token_renew + persona_analyze_* + outbox publish
@@ -47,6 +50,8 @@ import (
// cd apps/backend && go build -o bin/worker ./cmd/worker
// ./bin/worker -f etc/gateway.yaml
func main() {
+ securelog.SilenceMongo()
+
configFile := flag.String("f", "etc/gateway.yaml", "config file")
flag.Parse()
@@ -56,6 +61,13 @@ func main() {
if err := c.ValidateProductionDependencies(); err != nil {
logx.Must(err)
}
+ redisClient := redis.MustNewRedis(c.CacheRedis[0].RedisConf)
+ pingCtx, cancelPing := context.WithTimeout(context.Background(), 3*time.Second)
+ redisReady := redisClient.PingCtx(pingCtx)
+ cancelPing()
+ if !redisReady {
+ logx.Must(fmt.Errorf("redis startup ping failed"))
+ }
workerID := c.Worker.ID
if workerID == "" || workerID == "worker-1" || workerID == "worker-local-1" || workerID == "demo-worker-1" {
@@ -86,7 +98,11 @@ func main() {
threadsSvc.Renew = jobs
// usage + AI keys(與 gateway 對齊,persona LLM 需真 key)
- members := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis)
+ settingsCodec, err := c.NewMemberSettingsCodec()
+ if err != nil {
+ logx.Must(err)
+ }
+ members := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis, c.CacheRedis[0].RedisConf, c.Redis.Namespace, settingsCodec)
keyRes := &usageUC.SettingsResolver{
Members: members,
PlatformAI: c.Platform.AIKey,
@@ -95,7 +111,9 @@ func main() {
PlatformExa: c.Platform.ExaKey,
}
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
- aiRegistry := ai.NewRegistry()
+ usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60, c.Redis.Namespace)
+ modelsCache := ai.NewRedisModelsCache(c.CacheRedis[0].RedisConf, c.Redis.Namespace)
+ aiRegistry := ai.NewRegistry(modelsCache, c.Redis.AIModelCacheFingerprintSecret)
var pub studioPublish.Transport
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
@@ -105,6 +123,7 @@ func main() {
pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
}
studio := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
+ studio.OutboxWorkerID = workerID
studio.Accounts = threadsSvc
studio.Usage = usageSvc
studio.AI = &ai.FakeClient{}
@@ -133,7 +152,9 @@ func main() {
defer tick.Stop()
ctx := context.Background()
- outboxLock := redislock.New(c.CacheRedis[0].RedisConf, "haixun:worker:outbox", workerID, 60*time.Second)
+ const outboxLockTTL = 3 * time.Minute
+ outboxLockKey := strings.Trim(c.Redis.Namespace, ":") + ":worker:outbox"
+ outboxLock := redislock.NewWithClient(redisClient, outboxLockKey, workerID, outboxLockTTL)
for {
select {
case <-sig:
@@ -194,22 +215,8 @@ func main() {
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
}
}
- // 2) One worker owns an outbox tick. The Redis value is workerID and
- // release is compare-and-delete, so a departing worker cannot unlock a
- // successor's lease.
- if locked, lerr := outboxLock.Acquire(ctx); lerr != nil {
- logx.Errorf("worker %s outbox lock: %v", workerID, lerr)
- } else if locked {
- n, err := studio.ProcessDueSteps(ctx, 0)
- if rerr := outboxLock.Release(ctx); rerr != nil {
- logx.Errorf("worker %s outbox unlock: %v", workerID, rerr)
- }
- 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)
- }
- }
+ // 2) One worker owns an outbox tick and renews its lease while claims run.
+ processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL)
// 3) 終態任務超過 2 天自動清除
if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
logx.Errorf("worker %s purge jobs: %v", workerID, err)
@@ -220,6 +227,66 @@ func main() {
}
}
+func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redislock.Lock, workerID string, ttl time.Duration) {
+ locked, err := lock.Acquire(ctx)
+ if err != nil {
+ logx.Errorf("worker %s outbox lock acquire: %v", workerID, err)
+ return
+ }
+ if !locked {
+ return
+ }
+
+ runCtx, cancel := context.WithCancel(ctx)
+ renewFailure := make(chan error, 1)
+ renewStopped := make(chan struct{})
+ go func() {
+ defer close(renewStopped)
+ ticker := time.NewTicker(ttl / 3)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-runCtx.Done():
+ return
+ case <-ticker.C:
+ renewed, renewErr := lock.Renew(runCtx)
+ if renewErr != nil {
+ renewFailure <- fmt.Errorf("renew: %w", renewErr)
+ cancel()
+ return
+ }
+ if !renewed {
+ renewFailure <- errors.New("lease ownership lost")
+ cancel()
+ return
+ }
+ }
+ }
+ }()
+ defer func() {
+ cancel()
+ <-renewStopped
+ releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer releaseCancel()
+ if err := lock.Release(releaseCtx); err != nil {
+ logx.Errorf("worker %s outbox lock release: %v", workerID, err)
+ }
+ }()
+
+ n, processErr := studio.ProcessDueSteps(runCtx, 0)
+ select {
+ case renewErr := <-renewFailure:
+ logx.Errorf("worker %s outbox lock renewal failed; claims canceled: %v", workerID, renewErr)
+ return
+ default:
+ }
+ if processErr != nil {
+ logx.Errorf("worker %s outbox tick: %v", workerID, processErr)
+ } else if n > 0 {
+ logx.Infof("worker %s outbox published %d step(s)", workerID, n)
+ }
+}
+
func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error {
var brief scoutDomain.RunBrief
if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil {
@@ -349,7 +416,10 @@ type workerAIKeys struct {
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 {
+ if err != nil {
+ return "", "", "", err
+ }
+ if st == nil {
st = memberDomain.DefaultSettings(ownerUID)
}
// 完全依會員 AI 設定(provider / model / key),不寫死模型
@@ -360,6 +430,9 @@ func (k *workerAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider,
}
if k.Resolver != nil {
_, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy)
+ if rerr != nil && !errors.Is(rerr, usageDomain.ErrNoKey) {
+ return provider, model, "", rerr
+ }
if rerr == nil && strings.TrimSpace(key) != "" {
return provider, model, key, nil
}
@@ -379,7 +452,10 @@ func (d *workerDevMode) DevModeEnabled(ctx context.Context, uid int64) (bool, er
return false, nil
}
st, err := d.Members.GetSettings(ctx, uid)
- if err != nil || st == nil {
+ if err != nil {
+ return false, err
+ }
+ if st == nil {
return false, nil
}
return st.DevModeEnabled, nil
diff --git a/apps/backend/etc/gateway.example.yaml b/apps/backend/etc/gateway.example.yaml
index 087a908..153eef0 100644
--- a/apps/backend/etc/gateway.example.yaml
+++ b/apps/backend/etc/gateway.example.yaml
@@ -19,6 +19,13 @@ CacheRedis:
Type: node
Pass: ""
+# Shared key prefix for this environment and a dedicated HMAC secret for
+# credential-safe AI model-cache fingerprints. Prefer env REDIS_NAMESPACE and
+# AI_MODEL_CACHE_FINGERPRINT_SECRET; do not reuse JWT, Scout, or encryption keys.
+Redis:
+ Namespace: "haixun:dev:v1"
+ AIModelCacheFingerprintSecret: "REPLACE_WITH_A_DEDICATED_LONG_RANDOM_SECRET"
+
# Mongo — mon/monc 使用完整 URI(含帳密時寫在 URI;可用環境變數 MONGO_URI 覆寫)
# 本機有 auth 的例子:
# export MONGO_URI='mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin'
@@ -34,6 +41,13 @@ Auth:
RefreshSecret: "REPLACE_WITH_A_DIFFERENT_LONG_RANDOM_REFRESH_SECRET"
RefreshExpire: 604800
+# AES-256-GCM for member BYOK at rest. Generate Key with: openssl rand -base64 32
+# Prefer env MEMBER_SETTINGS_ENCRYPTION_KEY_ID / MEMBER_SETTINGS_ENCRYPTION_KEY.
+# Keep this key ID/key unchanged until persisted values have been migrated to a replacement.
+MemberSettingsEncryption:
+ CurrentKeyID: "v1"
+ Key: "REPLACE_WITH_BASE64_32_BYTE_KEY"
+
# 平台預設 key(所有會員可用;會員在設定頁填的 BYOK 優先)
# 也可用環境變數覆寫:PLATFORM_XAI_KEY / PLATFORM_OPENCODE_KEY / PLATFORM_EXA_KEY / PLATFORM_AI_KEY
Platform:
@@ -74,6 +88,18 @@ OAuth:
LineClientSecret: ""
LineRedirectURI: ""
+# Prefer STRIPE_* environment variables. Disabled/missing configuration never
+# blocks startup; Stripe-dependent billing operations explicitly return 503.
+Stripe:
+ Enabled: false
+ SecretKey: ""
+ WebhookSecret: ""
+ StarterPriceID: ""
+ ProPriceID: ""
+ SuccessURL: ""
+ CancelURL: ""
+ PortalReturnURL: ""
+
# 驗證碼/重設密碼寄信
#
# 本機(Mailpit,Mailgun 相容 SMTP 介面):
diff --git a/apps/backend/gateway.go b/apps/backend/gateway.go
index d347adc..da21eef 100644
--- a/apps/backend/gateway.go
+++ b/apps/backend/gateway.go
@@ -1,9 +1,11 @@
package main
import (
+ "context"
"flag"
"fmt"
"os"
+ "time"
"apps/backend/internal/config"
"apps/backend/internal/handler"
@@ -41,6 +43,12 @@ func main() {
server.Use(middleware.NewCorsMiddleware(c.CORSAllowedOrigins).Handle)
ctx := svc.NewServiceContext(c)
+ pingCtx, cancelPing := context.WithTimeout(context.Background(), 3*time.Second)
+ redisReady := ctx.Redis != nil && ctx.Redis.PingCtx(pingCtx)
+ cancelPing()
+ if !redisReady {
+ logx.Must(fmt.Errorf("redis startup ping failed"))
+ }
handler.RegisterHandlers(server, ctx)
// 靈感 SSE stream(goctl 不生成 streaming handler)
handler.RegisterInspireStream(server, ctx)
diff --git a/apps/backend/generate/api/billing.api b/apps/backend/generate/api/billing.api
new file mode 100644
index 0000000..9d5ba06
--- /dev/null
+++ b/apps/backend/generate/api/billing.api
@@ -0,0 +1,69 @@
+syntax = "v1"
+
+type (
+ BillingCheckoutCreateReq {
+ PlanId string `json:"plan_id"`
+ RequestId string `json:"request_id"`
+ }
+
+ BillingCheckoutData {
+ Id string `json:"id"`
+ SessionId string `json:"session_id"`
+ Url string `json:"url"`
+ ExpiresAt int64 `json:"expires_at"`
+ Status string `json:"status,optional"`
+ PaymentStatus string `json:"payment_status,optional"`
+ FulfillmentStatus string `json:"fulfillment_status,optional"`
+ PlanId string `json:"plan_id,optional"`
+ }
+
+ BillingCheckoutGetReq {
+ Id string `path:"id"`
+ }
+
+ BillingSubscriptionData {
+ PlanId string `json:"plan_id"`
+ PaidPlanId string `json:"paid_plan_id,optional"`
+ Status string `json:"status"`
+ CustomerId string `json:"customer_id,optional"`
+ SubscriptionId string `json:"subscription_id,optional"`
+ CurrentPeriodStart int64 `json:"current_period_start,optional"`
+ CurrentPeriodEnd int64 `json:"current_period_end,optional"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ AdminOverride bool `json:"admin_override"`
+ }
+
+ BillingPortalData {
+ Url string `json:"url"`
+ }
+)
+
+@server (
+ jwt: Auth
+ group: billing
+ prefix: /api/v1/billing
+ middleware: AuthJWT
+)
+service gateway {
+ @handler CreateBillingCheckoutSession
+ post /checkout-sessions (BillingCheckoutCreateReq) returns (BillingCheckoutData)
+
+ @handler GetBillingCheckoutSession
+ get /checkout-sessions/:id (BillingCheckoutGetReq) returns (BillingCheckoutData)
+
+ @handler GetBillingSubscription
+ get /subscription returns (BillingSubscriptionData)
+
+ @handler CreateBillingPortalSession
+ post /portal-sessions returns (BillingPortalData)
+}
+
+@server (
+ group: billing
+ prefix: /api/v1/billing
+ middleware: StripeRawBody
+)
+service gateway {
+ @handler HandleStripeWebhook
+ post /stripe/webhook
+}
diff --git a/apps/backend/generate/api/gateway.api b/apps/backend/generate/api/gateway.api
index 08e7bb9..6df0038 100644
--- a/apps/backend/generate/api/gateway.api
+++ b/apps/backend/generate/api/gateway.api
@@ -17,6 +17,7 @@ import "threads.api"
import "jobs.api"
import "notifications.api"
import "usage.api"
+import "billing.api"
import "proxy.api"
import "extension.api"
import "studio.api"
diff --git a/apps/backend/generate/api/usage.api b/apps/backend/generate/api/usage.api
index a9edcf4..1bff30c 100644
--- a/apps/backend/generate/api/usage.api
+++ b/apps/backend/generate/api/usage.api
@@ -101,6 +101,54 @@ type (
UsageTenantSummaryReq {
MonthKey string `form:"month_key,optional"`
}
+
+ UsageTenantAnalyticsReq {
+ Granularity string `form:"granularity"` // day|month|year
+ From string `form:"from"` // inclusive YYYY-MM-DD UTC
+ To string `form:"to"` // inclusive YYYY-MM-DD UTC
+ }
+
+ UsageTenantAnalyticsBucket {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ PurchasedCredits int `json:"purchased_credits"`
+ ConsumedCredits int `json:"consumed_credits"`
+ AiCalls int `json:"ai_calls"`
+ SearchCalls int `json:"search_calls"`
+ ByokCallCount int `json:"byok_call_count"`
+ }
+
+ UsageTenantAnalyticsMember {
+ Uid int64 `json:"uid"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Unlimited bool `json:"unlimited"`
+ PlanId string `json:"plan_id"`
+ PurchasedCredits int `json:"purchased_credits"`
+ TotalCredits int `json:"total_credits"`
+ AiCalls int `json:"ai_calls"`
+ SearchCalls int `json:"search_calls"`
+ ByokCallCount int `json:"byok_call_count"`
+ RemainingCredits int `json:"remaining_credits"`
+ Pct int `json:"pct"`
+ }
+
+ UsageTenantAnalyticsData {
+ Granularity string `json:"granularity"`
+ From string `json:"from"`
+ To string `json:"to"`
+ RangeLabel string `json:"range_label"`
+ PurchasedCredits int `json:"purchased_credits"`
+ ConsumedCredits int `json:"consumed_credits"`
+ RemainingCredits int `json:"remaining_credits"`
+ Pct int `json:"pct"`
+ AiCalls int `json:"ai_calls"`
+ SearchCalls int `json:"search_calls"`
+ ByMeter map[string]UsageMeterCount `json:"by_meter"`
+ Byok UsageByokBlock `json:"byok"`
+ Series []UsageTenantAnalyticsBucket `json:"series"`
+ Members []UsageTenantAnalyticsMember `json:"members"`
+ }
)
@server (
@@ -138,4 +186,7 @@ service gateway {
@handler GetTenantUsageSummary
get /tenant-summary (UsageTenantSummaryReq) returns (UsageTenantSummaryData)
+
+ @handler GetTenantUsageAnalytics
+ get /tenant-analytics (UsageTenantAnalyticsReq) returns (UsageTenantAnalyticsData)
}
diff --git a/apps/backend/generate/database/README.md b/apps/backend/generate/database/README.md
index 1087f6d..c986d9b 100644
--- a/apps/backend/generate/database/README.md
+++ b/apps/backend/generate/database/README.md
@@ -49,16 +49,16 @@ make migrate-down
| 類型 | 放哪 | 範例 |
|------|------|------|
| Index / collection 約束 | `*.up.json` createIndexes | members.uid unique |
-| 種子資料 | 另開 version(如 000003_seed_*) | admin 帳 |
+| 種子資料 | 不放 migration | 使用 `cmd/seeder` 與 runtime secret |
| 業務邏輯、密碼 runtime 產生 | **不要**寫進 migration | 用 API / 測試 helper |
-種子 admin 預設:`admin@haixun.local` / `admin123`(hash 寫死在 up 檔;正式環境請改或不要跑 seed migration)。
+Migration 不建立任何預設帳號。首次管理員必須透過 `make seeder`,並由環境提供強密碼。
## 與 gateway
先 `make migrate-up`,再 `go run . -f etc/gateway.yaml`(需 Mongo + Redis)。
## 不要
-- 手寫 Go seeder/init
+- 在 migration 寫死帳號或密碼
- 手寫 migration runner
- 把 stand-alone 電商 `.txt` 當 migrate 來源(格式不同;要用 JSON runCommand)
diff --git a/apps/backend/generate/database/mongo/000003_seed_admin.down.json b/apps/backend/generate/database/mongo/000003_seed_admin.down.json
index faddfb0..fe51488 100644
--- a/apps/backend/generate/database/mongo/000003_seed_admin.down.json
+++ b/apps/backend/generate/database/mongo/000003_seed_admin.down.json
@@ -1,11 +1 @@
-[
- {
- "delete": "members",
- "deletes": [
- {
- "q": { "uid": 1, "email": "admin@30cm.net" },
- "limit": 1
- }
- ]
- }
-]
+[]
diff --git a/apps/backend/generate/database/mongo/000003_seed_admin.up.json b/apps/backend/generate/database/mongo/000003_seed_admin.up.json
index 7aedd33..134edc1 100644
--- a/apps/backend/generate/database/mongo/000003_seed_admin.up.json
+++ b/apps/backend/generate/database/mongo/000003_seed_admin.up.json
@@ -1,28 +1,4 @@
[
- {
- "insert": "members",
- "documents": [
- {
- "uid": 1000000,
- "tenant_id": "default",
- "email": "admin@30cm.net",
- "display_name": "Harbor Admin",
- "roles": ["admin", "member"],
- "status": "active",
- "email_verified": true,
- "email_verified_at": 1700000000000000000,
- "invite_code": "SEEDADMIN",
- "password_hash": "$2a$10$iGGFNC5rM8MFhU8Wj6a7TOcQCsTeD03bkEODvQBh2ZxvCH.PcLl3O",
- "timezone": "Asia/Taipei",
- "notify_email": false,
- "bio": "",
- "created_at": 1700000000000000000,
- "updated_at": 1700000000000000000,
- "seed_note": "password is admin123 — change in production; uid=1000000 (8 digits from 1M)"
- }
- ],
- "ordered": false
- },
{
"update": "counters",
"updates": [
diff --git a/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json b/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json
index 17d9551..fe51488 100644
--- a/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json
+++ b/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json
@@ -1,11 +1 @@
-[
- {
- "delete": "identities",
- "deletes": [
- {
- "q": { "uid": 1, "login_id": "admin@haixun.local", "platform": "platform" },
- "limit": 1
- }
- ]
- }
-]
+[]
diff --git a/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json b/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json
index f3dc0c0..fe51488 100644
--- a/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json
+++ b/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json
@@ -1,17 +1 @@
-[
- {
- "insert": "identities",
- "documents": [
- {
- "uid": 1000000,
- "login_id": "admin@haixun.local",
- "platform": "platform",
- "account_type": "email",
- "password_hash": "$2a$10$iGGFNC5rM8MFhU8Wj6a7TOcQCsTeD03bkEODvQBh2ZxvCH.PcLl3O",
- "created_at": 1700000000000000000,
- "updated_at": 1700000000000000000
- }
- ],
- "ordered": false
- }
-]
+[]
diff --git a/apps/backend/generate/database/mongo/000009_usage_monthly_counters.down.json b/apps/backend/generate/database/mongo/000009_usage_monthly_counters.down.json
new file mode 100644
index 0000000..0af2c8d
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000009_usage_monthly_counters.down.json
@@ -0,0 +1,5 @@
+[
+ { "dropIndexes": "usage_monthly_counters", "index": "usage_counter_uid_month" },
+ { "dropIndexes": "usage_events", "index": "usage_events_member_month_mode" },
+ { "dropIndexes": "usage_events", "index": "usage_events_month" }
+]
diff --git a/apps/backend/generate/database/mongo/000009_usage_monthly_counters.up.json b/apps/backend/generate/database/mongo/000009_usage_monthly_counters.up.json
new file mode 100644
index 0000000..245e562
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000009_usage_monthly_counters.up.json
@@ -0,0 +1,25 @@
+[
+ {
+ "createIndexes": "usage_monthly_counters",
+ "indexes": [
+ {
+ "key": { "uid": 1, "month_key": 1 },
+ "name": "usage_counter_uid_month",
+ "unique": true
+ }
+ ]
+ },
+ {
+ "createIndexes": "usage_events",
+ "indexes": [
+ {
+ "key": { "uid": 1, "month_key": 1, "key_mode": 1, "created_at": -1 },
+ "name": "usage_events_member_month_mode"
+ },
+ {
+ "key": { "month_key": 1, "created_at": -1 },
+ "name": "usage_events_month"
+ }
+ ]
+ }
+]
diff --git a/apps/backend/generate/database/mongo/000010_outbox_step_lease_index.down.json b/apps/backend/generate/database/mongo/000010_outbox_step_lease_index.down.json
new file mode 100644
index 0000000..abf9c91
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000010_outbox_step_lease_index.down.json
@@ -0,0 +1,3 @@
+[
+ { "dropIndexes": "studio_outbox", "index": "claim_due_outbox_steps" }
+]
diff --git a/apps/backend/generate/database/mongo/000010_outbox_step_lease_index.up.json b/apps/backend/generate/database/mongo/000010_outbox_step_lease_index.up.json
new file mode 100644
index 0000000..ba79877
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000010_outbox_step_lease_index.up.json
@@ -0,0 +1,11 @@
+[
+ {
+ "createIndexes": "studio_outbox",
+ "indexes": [
+ {
+ "key": { "steps.status": 1, "steps.scheduled_at": 1, "steps.lease_expires_at": 1 },
+ "name": "claim_due_outbox_steps"
+ }
+ ]
+ }
+]
diff --git a/apps/backend/generate/database/mongo/000011_stripe_billing_indexes.down.json b/apps/backend/generate/database/mongo/000011_stripe_billing_indexes.down.json
new file mode 100644
index 0000000..9987e32
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000011_stripe_billing_indexes.down.json
@@ -0,0 +1,11 @@
+[
+ { "dropIndexes": "usage_prefs", "index": "unique_usage_prefs_uid" },
+ { "dropIndexes": "usage_prefs", "index": "unique_stripe_customer" },
+ { "dropIndexes": "usage_prefs", "index": "unique_stripe_subscription" },
+ { "dropIndexes": "billing_checkout_attempts", "index": "unique_checkout_request" },
+ { "dropIndexes": "billing_checkout_attempts", "index": "unique_checkout_session" },
+ { "dropIndexes": "billing_webhook_events", "index": "stripe_webhook_event_id" },
+ { "dropIndexes": "usage_purchases", "index": "unique_purchase_stripe_event" },
+ { "dropIndexes": "usage_purchases", "index": "purchase_stripe_session" },
+ { "dropIndexes": "usage_purchases", "index": "purchase_stripe_subscription" }
+]
diff --git a/apps/backend/generate/database/mongo/000011_stripe_billing_indexes.up.json b/apps/backend/generate/database/mongo/000011_stripe_billing_indexes.up.json
new file mode 100644
index 0000000..c8efb21
--- /dev/null
+++ b/apps/backend/generate/database/mongo/000011_stripe_billing_indexes.up.json
@@ -0,0 +1,31 @@
+[
+ {
+ "createIndexes": "usage_prefs",
+ "indexes": [
+ { "key": { "uid": 1 }, "name": "unique_usage_prefs_uid", "unique": true },
+ { "key": { "stripe_customer_id": 1 }, "name": "unique_stripe_customer", "unique": true, "sparse": true },
+ { "key": { "stripe_subscription_id": 1 }, "name": "unique_stripe_subscription", "unique": true, "sparse": true }
+ ]
+ },
+ {
+ "createIndexes": "billing_checkout_attempts",
+ "indexes": [
+ { "key": { "uid": 1, "request_id": 1 }, "name": "unique_checkout_request", "unique": true },
+ { "key": { "session_id": 1 }, "name": "unique_checkout_session", "unique": true, "sparse": true }
+ ]
+ },
+ {
+ "createIndexes": "billing_webhook_events",
+ "indexes": [
+ { "key": { "stripe_event_id": 1 }, "name": "stripe_webhook_event_id", "unique": true }
+ ]
+ },
+ {
+ "createIndexes": "usage_purchases",
+ "indexes": [
+ { "key": { "stripe_event_id": 1 }, "name": "unique_purchase_stripe_event", "unique": true, "sparse": true },
+ { "key": { "stripe_session_id": 1 }, "name": "purchase_stripe_session", "sparse": true },
+ { "key": { "stripe_subscription_id": 1 }, "name": "purchase_stripe_subscription", "sparse": true }
+ ]
+ }
+]
diff --git a/apps/backend/go.mod b/apps/backend/go.mod
index 19a7885..a6e85b1 100644
--- a/apps/backend/go.mod
+++ b/apps/backend/go.mod
@@ -3,8 +3,14 @@ module apps/backend
go 1.25.0
require (
+ github.com/aws/aws-sdk-go-v2 v1.42.1
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.28
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/golang-jwt/jwt/v5 v5.2.1
+ github.com/google/uuid v1.6.0
+ github.com/matcornic/hermes/v2 v2.1.0
github.com/stretchr/testify v1.10.0
+ github.com/stripe/stripe-go/v82 v82.5.1
github.com/zeromicro/go-zero v1.7.6
go.mongodb.org/mongo-driver v1.17.1
golang.org/x/crypto v0.54.0
@@ -16,9 +22,7 @@ require (
github.com/PuerkitoBio/goquery v1.5.0 // indirect
github.com/andybalholm/cascadia v1.0.0 // indirect
github.com/aokoli/goutils v1.0.1 // indirect
- github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
- github.com/aws/aws-sdk-go-v2/credentials v1.19.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
@@ -26,7 +30,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
- github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@@ -38,14 +41,12 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/huandu/xstrings v1.2.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
- github.com/matcornic/hermes/v2 v2.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
diff --git a/apps/backend/go.sum b/apps/backend/go.sum
index 0f182b7..38e4022 100644
--- a/apps/backend/go.sum
+++ b/apps/backend/go.sum
@@ -153,6 +153,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
+github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 h1:L0rPdfzq43+NV8rfIx2kA4iSSLRj2jN5ijYHoeXRwvQ=
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe h1:9YnI5plmy+ad6BM+JCLJb2ZV7/TNiE5l7SNKfumYKgc=
diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go
index 70c09ab..3304d33 100644
--- a/apps/backend/internal/config/config.go
+++ b/apps/backend/internal/config/config.go
@@ -2,9 +2,12 @@ package config
import (
"fmt"
+ "net/url"
"os"
"strings"
+ "apps/backend/internal/lib/secretbox"
+
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/rest"
)
@@ -15,6 +18,10 @@ type Config struct {
// CacheRedis is go-zero cache.CacheConf (feeds monc Redis 快取).
CacheRedis cache.CacheConf
+ Redis struct {
+ Namespace string `json:",optional,default=haixun:dev:v1"`
+ AIModelCacheFingerprintSecret string `json:",optional"`
+ }
Mongo struct {
URI string
@@ -27,6 +34,12 @@ type Config struct {
RefreshSecret string
RefreshExpire int64
}
+ // MemberSettingsEncryption is dedicated to member BYOK secrets. Never reuse
+ // Auth or Scout secrets here.
+ MemberSettingsEncryption struct {
+ CurrentKeyID string `json:",optional"`
+ Key string `json:",optional"`
+ }
// CORSAllowedOrigins is an explicit browser-origin allowlist.
CORSAllowedOrigins []string `json:",optional"`
Platform struct {
@@ -65,6 +78,16 @@ type Config struct {
LineClientSecret string `json:",optional"`
LineRedirectURI string `json:",optional"`
}
+ Stripe struct {
+ Enabled bool `json:",optional"`
+ SecretKey string `json:",optional"`
+ WebhookSecret string `json:",optional"`
+ StarterPriceID string `json:",optional"`
+ ProPriceID string `json:",optional"`
+ SuccessURL string `json:",optional"`
+ CancelURL string `json:",optional"`
+ PortalReturnURL string `json:",optional"`
+ }
// Mail — 驗證碼/重設密碼寄信(SMTP;空 Host 則只打 log + 可回 mock_code)
Mail struct {
Sender string `json:",optional"` // e.g. "Harbor Desk "
@@ -86,9 +109,9 @@ type Config struct {
PublicAPIBase string `json:",optional"`
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg
Brand struct {
- Name string `json:",optional,default=Harbor Desk"`
+ Name string `json:",optional"`
LogoURL string `json:",optional"` // absolute URL
- Copyright string `json:",optional,default=© Harbor Desk"`
+ Copyright string `json:",optional"`
} `json:",optional"`
// ObjectStorage — 頭像等二進位;**僅 MinIO / S3**(不落本機磁碟)
ObjectStorage struct {
@@ -104,6 +127,15 @@ type Config struct {
// ApplyEnv overlays secrets from environment.
func (c *Config) ApplyEnv() {
+ if strings.TrimSpace(c.Redis.Namespace) == "" {
+ c.Redis.Namespace = "haixun:dev:v1"
+ }
+ if c.Brand.Name == "" {
+ c.Brand.Name = "Harbor Desk"
+ }
+ if c.Brand.Copyright == "" {
+ c.Brand.Copyright = "© Harbor Desk"
+ }
if v := os.Getenv("MONGO_URI"); v != "" {
c.Mongo.URI = v
}
@@ -124,12 +156,48 @@ func (c *Config) ApplyEnv() {
c.CacheRedis[0].RedisConf.Host = v
}
}
+ if v := firstEnv("REDIS_NAMESPACE", "HAIXUN_REDIS_NAMESPACE"); v != "" {
+ c.Redis.Namespace = strings.Trim(v, ":")
+ }
+ if v := firstEnv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET"); v != "" {
+ c.Redis.AIModelCacheFingerprintSecret = v
+ }
if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" {
c.Auth.AccessSecret = v
}
if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" {
c.Auth.RefreshSecret = v
}
+ if v := os.Getenv("STRIPE_ENABLED"); v != "" {
+ c.Stripe.Enabled = v == "1" || strings.EqualFold(v, "true")
+ }
+ if v := os.Getenv("STRIPE_SECRET_KEY"); v != "" {
+ c.Stripe.SecretKey = v
+ }
+ if v := os.Getenv("STRIPE_WEBHOOK_SECRET"); v != "" {
+ c.Stripe.WebhookSecret = v
+ }
+ if v := os.Getenv("STRIPE_STARTER_PRICE_ID"); v != "" {
+ c.Stripe.StarterPriceID = v
+ }
+ if v := os.Getenv("STRIPE_PRO_PRICE_ID"); v != "" {
+ c.Stripe.ProPriceID = v
+ }
+ if v := os.Getenv("STRIPE_SUCCESS_URL"); v != "" {
+ c.Stripe.SuccessURL = v
+ }
+ if v := os.Getenv("STRIPE_CANCEL_URL"); v != "" {
+ c.Stripe.CancelURL = v
+ }
+ if v := os.Getenv("STRIPE_PORTAL_RETURN_URL"); v != "" {
+ c.Stripe.PortalReturnURL = v
+ }
+ if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY_ID"); v != "" {
+ c.MemberSettingsEncryption.CurrentKeyID = v
+ }
+ if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY"); v != "" {
+ c.MemberSettingsEncryption.Key = v
+ }
if v := os.Getenv("PLATFORM_AI_KEY"); v != "" {
c.Platform.AIKey = v
}
@@ -152,18 +220,32 @@ func (c *Config) ApplyEnv() {
if v := firstEnv("THREADS_APP_SECRET", "HAIXUN_THREADS_APP_SECRET"); v != "" {
c.Platform.ThreadsAppSecret = v
}
+ if v := os.Getenv("SCOUT_SESSION_SECRET"); v != "" {
+ c.Scout.SessionSecret = v
+ }
+ if v := os.Getenv("SCOUT_CRAWLER_ENDPOINT"); v != "" {
+ c.Scout.CrawlerEndpoint = v
+ }
+ if v := os.Getenv("SCOUT_CRAWLER_TOKEN"); v != "" {
+ c.Scout.CrawlerToken = v
+ }
+ if v := os.Getenv("SEED_ADMIN_PASSWORD"); v != "" {
+ c.Seed.AdminPassword = v
+ }
if v := os.Getenv("WORKER_ID"); v != "" {
c.Worker.ID = v
}
if v := os.Getenv("PORT"); v != "" {
var p int
+ valid := true
for _, ch := range v {
if ch < '0' || ch > '9' {
- return
+ valid = false
+ break
}
p = p*10 + int(ch-'0')
}
- if p > 0 {
+ if valid && p > 0 {
c.Port = p
}
}
@@ -246,6 +328,28 @@ func (c Config) ValidateProductionDependencies() error {
if c.Auth.AccessSecret == c.Auth.RefreshSecret {
return fmt.Errorf("AUTH_ACCESS_SECRET and AUTH_REFRESH_SECRET must differ")
}
+ fingerprintSecret := strings.TrimSpace(c.Redis.AIModelCacheFingerprintSecret)
+ if invalidSecret(fingerprintSecret) {
+ return fmt.Errorf("AI_MODEL_CACHE_FINGERPRINT_SECRET must be at least 32 bytes")
+ }
+ if fingerprintSecret == strings.TrimSpace(c.Auth.AccessSecret) ||
+ fingerprintSecret == strings.TrimSpace(c.Auth.RefreshSecret) ||
+ fingerprintSecret == strings.TrimSpace(c.Scout.SessionSecret) ||
+ fingerprintSecret == strings.TrimSpace(c.MemberSettingsEncryption.Key) {
+ return fmt.Errorf("AI model cache fingerprint secret must be dedicated")
+ }
+ if namespace := strings.TrimSpace(c.Redis.Namespace); namespace == "" || strings.ContainsAny(namespace, "{} \t\r\n") {
+ return fmt.Errorf("Redis.Namespace must be non-empty and must not contain whitespace or braces")
+ }
+ if _, err := c.NewMemberSettingsCodec(); err != nil {
+ return fmt.Errorf("member settings encryption: %w", err)
+ }
+ encryptionKey := strings.TrimSpace(c.MemberSettingsEncryption.Key)
+ if encryptionKey == strings.TrimSpace(c.Auth.AccessSecret) ||
+ encryptionKey == strings.TrimSpace(c.Auth.RefreshSecret) ||
+ encryptionKey == strings.TrimSpace(c.Scout.SessionSecret) {
+ return fmt.Errorf("member settings encryption key must not reuse Auth or Scout secrets")
+ }
if len(c.CacheRedis) == 0 || strings.TrimSpace(c.CacheRedis[0].Host) == "" {
return fmt.Errorf("CacheRedis is required")
}
@@ -255,14 +359,56 @@ func (c Config) ValidateProductionDependencies() error {
if len(c.CORSAllowedOrigins) == 0 {
return fmt.Errorf("CORSAllowedOrigins is required")
}
+ if err := validateStripe(c.Stripe.Enabled, c.Stripe.SecretKey, c.Stripe.WebhookSecret, c.Stripe.StarterPriceID, c.Stripe.ProPriceID, c.Stripe.SuccessURL, c.Stripe.CancelURL, c.Stripe.PortalReturnURL); err != nil {
+ return err
+ }
return nil
}
+// NewMemberSettingsCodec constructs the dedicated BYOK-at-rest codec.
+func (c Config) NewMemberSettingsCodec() (*secretbox.Codec, error) {
+ return secretbox.New(c.MemberSettingsEncryption.CurrentKeyID, c.MemberSettingsEncryption.Key)
+}
+
func invalidSecret(value string) bool {
value = strings.TrimSpace(value)
return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME")
}
+func validateStripe(enabled bool, secretKey, webhookSecret, starterPriceID, proPriceID, successURL, cancelURL, portalReturnURL string) error {
+ if !enabled {
+ return nil
+ }
+ if !strings.HasPrefix(strings.TrimSpace(secretKey), "sk_") {
+ return fmt.Errorf("STRIPE_SECRET_KEY is required when Stripe is enabled")
+ }
+ if !strings.HasPrefix(strings.TrimSpace(webhookSecret), "whsec_") {
+ return fmt.Errorf("STRIPE_WEBHOOK_SECRET is required when Stripe is enabled")
+ }
+ starterPriceID = strings.TrimSpace(starterPriceID)
+ proPriceID = strings.TrimSpace(proPriceID)
+ if !strings.HasPrefix(starterPriceID, "price_") || !strings.HasPrefix(proPriceID, "price_") {
+ return fmt.Errorf("STRIPE_STARTER_PRICE_ID and STRIPE_PRO_PRICE_ID are required when Stripe is enabled")
+ }
+ if starterPriceID == proPriceID {
+ return fmt.Errorf("Stripe Starter and Pro Price IDs must differ")
+ }
+ if !strings.Contains(successURL, "{CHECKOUT_ID}") {
+ return fmt.Errorf("STRIPE_SUCCESS_URL must contain {CHECKOUT_ID}")
+ }
+ for name, value := range map[string]string{
+ "STRIPE_SUCCESS_URL": successURL,
+ "STRIPE_CANCEL_URL": cancelURL,
+ "STRIPE_PORTAL_RETURN_URL": portalReturnURL,
+ } {
+ parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
+ if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
+ return fmt.Errorf("%s must be an absolute HTTPS URL", name)
+ }
+ }
+ return nil
+}
+
func firstEnv(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
diff --git a/apps/backend/internal/config/config_test.go b/apps/backend/internal/config/config_test.go
new file mode 100644
index 0000000..b4620dc
--- /dev/null
+++ b/apps/backend/internal/config/config_test.go
@@ -0,0 +1,109 @@
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestApplyEnvRedisCacheSettings(t *testing.T) {
+ t.Setenv("REDIS_NAMESPACE", "tenant:prod:v2")
+ t.Setenv("HAIXUN_REDIS_NAMESPACE", "")
+ t.Setenv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "dedicated-model-cache-secret-at-least-32-bytes")
+ t.Setenv("HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET", "")
+
+ var c Config
+ c.ApplyEnv()
+ if c.Redis.Namespace != "tenant:prod:v2" {
+ t.Fatalf("namespace=%q", c.Redis.Namespace)
+ }
+ if c.Redis.AIModelCacheFingerprintSecret != "dedicated-model-cache-secret-at-least-32-bytes" {
+ t.Fatalf("fingerprint secret environment override was not applied")
+ }
+}
+
+func TestApplyEnvDefaultsRedisNamespace(t *testing.T) {
+ t.Setenv("REDIS_NAMESPACE", "")
+ t.Setenv("HAIXUN_REDIS_NAMESPACE", "")
+
+ var c Config
+ c.ApplyEnv()
+ if c.Redis.Namespace != "haixun:dev:v1" {
+ t.Fatalf("namespace=%q", c.Redis.Namespace)
+ }
+}
+
+func TestApplyEnvStripeSettings(t *testing.T) {
+ t.Setenv("STRIPE_ENABLED", "true")
+ t.Setenv("STRIPE_SECRET_KEY", "sk_test")
+ t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
+ t.Setenv("STRIPE_STARTER_PRICE_ID", "price_starter")
+ t.Setenv("STRIPE_PRO_PRICE_ID", "price_pro")
+ t.Setenv("STRIPE_SUCCESS_URL", "https://app.test/success")
+ t.Setenv("STRIPE_CANCEL_URL", "https://app.test/cancel")
+ t.Setenv("STRIPE_PORTAL_RETURN_URL", "https://app.test/billing")
+
+ var c Config
+ c.ApplyEnv()
+ if !c.Stripe.Enabled || c.Stripe.SecretKey != "sk_test" || c.Stripe.WebhookSecret != "whsec_test" || c.Stripe.StarterPriceID != "price_starter" || c.Stripe.ProPriceID != "price_pro" || c.Stripe.SuccessURL != "https://app.test/success" || c.Stripe.CancelURL != "https://app.test/cancel" || c.Stripe.PortalReturnURL != "https://app.test/billing" {
+ t.Fatalf("Stripe environment was not fully applied: %+v", c.Stripe)
+ }
+}
+
+func TestApplyEnvScoutAndSeedSecrets(t *testing.T) {
+ t.Setenv("SCOUT_SESSION_SECRET", "dedicated-scout-secret")
+ t.Setenv("SCOUT_CRAWLER_ENDPOINT", "http://127.0.0.1:3010")
+ t.Setenv("SCOUT_CRAWLER_TOKEN", "crawler-token")
+ t.Setenv("SEED_ADMIN_PASSWORD", "seed-password")
+
+ var c Config
+ c.ApplyEnv()
+ if c.Scout.SessionSecret != "dedicated-scout-secret" || c.Scout.CrawlerEndpoint != "http://127.0.0.1:3010" || c.Scout.CrawlerToken != "crawler-token" || c.Seed.AdminPassword != "seed-password" {
+ t.Fatalf("Scout or seed environment was not fully applied")
+ }
+}
+
+func TestApplyEnvInvalidPortDoesNotSkipLaterOverrides(t *testing.T) {
+ t.Setenv("PORT", "invalid")
+ t.Setenv("PUBLIC_WEB_BASE", "https://app.test")
+
+ var c Config
+ c.ApplyEnv()
+ if c.PublicWebBase != "https://app.test" {
+ t.Fatalf("invalid PORT skipped later environment overrides")
+ }
+}
+
+func TestValidateProductionDependenciesRequiresDedicatedCacheSecret(t *testing.T) {
+ var c Config
+ c.Auth.AccessSecret = strings.Repeat("a", 40)
+ c.Auth.RefreshSecret = strings.Repeat("b", 40)
+
+ err := c.ValidateProductionDependencies()
+ if err == nil || !strings.Contains(err.Error(), "AI_MODEL_CACHE_FINGERPRINT_SECRET") {
+ t.Fatalf("unexpected validation error %v", err)
+ }
+
+ c.Redis.AIModelCacheFingerprintSecret = c.Auth.AccessSecret
+ err = c.ValidateProductionDependencies()
+ if err == nil || !strings.Contains(err.Error(), "must be dedicated") {
+ t.Fatalf("unexpected reused-secret validation error %v", err)
+ }
+}
+
+func TestValidateStripeEnabledConfiguration(t *testing.T) {
+ valid := func(successURL string) error {
+ return validateStripe(true, "sk_test_key", "whsec_test_secret", "price_starter", "price_pro", successURL, "https://app.test/app/usage/checkout?result=cancel", "https://app.test/app/usage/plans")
+ }
+ if err := valid("https://app.test/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}"); err != nil {
+ t.Fatalf("valid Stripe configuration rejected: %v", err)
+ }
+ if err := valid("https://app.test/app/usage/checkout?result=success"); err == nil || !strings.Contains(err.Error(), "{CHECKOUT_ID}") {
+ t.Fatalf("missing checkout token validation error = %v", err)
+ }
+ if err := validateStripe(true, "sk_test_key", "whsec_test_secret", "price_same", "price_same", "https://app.test/success?checkout_id={CHECKOUT_ID}", "https://app.test/cancel", "https://app.test/plans"); err == nil || !strings.Contains(err.Error(), "must differ") {
+ t.Fatalf("duplicate Price ID validation error = %v", err)
+ }
+ if err := validateStripe(true, "sk_test_key", "whsec_test_secret", "price_starter", "price_pro", "http://app.test/success?checkout_id={CHECKOUT_ID}", "https://app.test/cancel", "https://app.test/plans"); err == nil || !strings.Contains(err.Error(), "HTTPS") {
+ t.Fatalf("insecure URL validation error = %v", err)
+ }
+}
diff --git a/apps/backend/internal/handler/billing/create_billing_checkout_session_handler.go b/apps/backend/internal/handler/billing/create_billing_checkout_session_handler.go
new file mode 100644
index 0000000..fe687e6
--- /dev/null
+++ b/apps/backend/internal/handler/billing/create_billing_checkout_session_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package billing
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/billing"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func CreateBillingCheckoutSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.BillingCheckoutCreateReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := billing.NewCreateBillingCheckoutSessionLogic(r.Context(), svcCtx)
+ data, err := l.CreateBillingCheckoutSession(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/billing/create_billing_portal_session_handler.go b/apps/backend/internal/handler/billing/create_billing_portal_session_handler.go
new file mode 100644
index 0000000..4fa2642
--- /dev/null
+++ b/apps/backend/internal/handler/billing/create_billing_portal_session_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package billing
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/billing"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func CreateBillingPortalSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := billing.NewCreateBillingPortalSessionLogic(r.Context(), svcCtx)
+ data, err := l.CreateBillingPortalSession()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/billing/get_billing_checkout_session_handler.go b/apps/backend/internal/handler/billing/get_billing_checkout_session_handler.go
new file mode 100644
index 0000000..dca72f2
--- /dev/null
+++ b/apps/backend/internal/handler/billing/get_billing_checkout_session_handler.go
@@ -0,0 +1,28 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package billing
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/billing"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+func GetBillingCheckoutSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.BillingCheckoutGetReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := billing.NewGetBillingCheckoutSessionLogic(r.Context(), svcCtx)
+ data, err := l.GetBillingCheckoutSession(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/billing/get_billing_subscription_handler.go b/apps/backend/internal/handler/billing/get_billing_subscription_handler.go
new file mode 100644
index 0000000..d51cb64
--- /dev/null
+++ b/apps/backend/internal/handler/billing/get_billing_subscription_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package billing
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/billing"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func GetBillingSubscriptionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := billing.NewGetBillingSubscriptionLogic(r.Context(), svcCtx)
+ data, err := l.GetBillingSubscription()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/handler/billing/handle_stripe_webhook_handler.go b/apps/backend/internal/handler/billing/handle_stripe_webhook_handler.go
new file mode 100644
index 0000000..c435ba1
--- /dev/null
+++ b/apps/backend/internal/handler/billing/handle_stripe_webhook_handler.go
@@ -0,0 +1,20 @@
+// Code generated by goctl. DO NOT EDIT.
+// goctl
+
+package billing
+
+import (
+ "net/http"
+
+ "apps/backend/internal/logic/billing"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+func HandleStripeWebhookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := billing.NewHandleStripeWebhookLogic(r.Context(), svcCtx)
+ err := l.HandleStripeWebhook()
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/apps/backend/internal/handler/ping/redis_health_test.go b/apps/backend/internal/handler/ping/redis_health_test.go
new file mode 100644
index 0000000..e87dc09
--- /dev/null
+++ b/apps/backend/internal/handler/ping/redis_health_test.go
@@ -0,0 +1,60 @@
+package ping
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+)
+
+type fakeRedisHealth bool
+
+func (f fakeRedisHealth) PingCtx(context.Context) bool { return bool(f) }
+
+func TestRedisUnavailableReturnsServiceUnavailable(t *testing.T) {
+ serviceContext := &svc.ServiceContext{Redis: fakeRedisHealth(false)}
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ }{
+ {name: "ping", handler: PingHandler(serviceContext)},
+ {name: "health", handler: HealthHandler(serviceContext)},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ test.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/"+test.name, nil))
+ if recorder.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
+ }
+ var envelope response.Envelope
+ if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Code != 503001 || envelope.Message != "redis unavailable" {
+ t.Fatalf("response = %#v", envelope)
+ }
+ })
+ }
+}
+
+func TestRedisAvailableKeepsPingContract(t *testing.T) {
+ serviceContext := &svc.ServiceContext{Redis: fakeRedisHealth(true)}
+ recorder := httptest.NewRecorder()
+ PingHandler(serviceContext).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/ping", nil))
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
+ }
+ var envelope response.Envelope
+ if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
+ t.Fatal(err)
+ }
+ if envelope.Code != 102000 {
+ t.Fatalf("code = %d, want 102000", envelope.Code)
+ }
+}
diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go
index 650d6ec..69a3688 100644
--- a/apps/backend/internal/handler/routes.go
+++ b/apps/backend/internal/handler/routes.go
@@ -8,6 +8,7 @@ import (
admin "apps/backend/internal/handler/admin"
auth "apps/backend/internal/handler/auth"
+ billing "apps/backend/internal/handler/billing"
compose "apps/backend/internal/handler/compose"
extension "apps/backend/internal/handler/extension"
inspire "apps/backend/internal/handler/inspire"
@@ -187,6 +188,50 @@ 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: "/checkout-sessions",
+ Handler: billing.CreateBillingCheckoutSessionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/checkout-sessions/:id",
+ Handler: billing.GetBillingCheckoutSessionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodPost,
+ Path: "/portal-sessions",
+ Handler: billing.CreateBillingPortalSessionHandler(serverCtx),
+ },
+ {
+ Method: http.MethodGet,
+ Path: "/subscription",
+ Handler: billing.GetBillingSubscriptionHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
+ rest.WithPrefix("/api/v1/billing"),
+ )
+
+ server.AddRoutes(
+ rest.WithMiddlewares(
+ []rest.Middleware{serverCtx.StripeRawBody},
+ []rest.Route{
+ {
+ Method: http.MethodPost,
+ Path: "/stripe/webhook",
+ Handler: billing.HandleStripeWebhookHandler(serverCtx),
+ },
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/billing"),
+ )
+
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@@ -999,6 +1044,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/prefs",
Handler: usage.SetUsagePrefsHandler(serverCtx),
},
+ {
+ Method: http.MethodGet,
+ Path: "/tenant-analytics",
+ Handler: usage.GetTenantUsageAnalyticsHandler(serverCtx),
+ },
{
Method: http.MethodGet,
Path: "/tenant-summary",
diff --git a/apps/backend/internal/handler/usage/get_tenant_usage_analytics_handler.go b/apps/backend/internal/handler/usage/get_tenant_usage_analytics_handler.go
new file mode 100644
index 0000000..4947f6e
--- /dev/null
+++ b/apps/backend/internal/handler/usage/get_tenant_usage_analytics_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 GetTenantUsageAnalyticsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UsageTenantAnalyticsReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := usage.NewGetTenantUsageAnalyticsLogic(r.Context(), svcCtx)
+ data, err := l.GetTenantUsageAnalytics(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/apps/backend/internal/lib/redislock/redislock.go b/apps/backend/internal/lib/redislock/redislock.go
index 125522f..e584e58 100644
--- a/apps/backend/internal/lib/redislock/redislock.go
+++ b/apps/backend/internal/lib/redislock/redislock.go
@@ -2,46 +2,155 @@ package redislock
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"fmt"
+ "strings"
+ "sync"
"time"
"github.com/zeromicro/go-zero/core/stores/redis"
)
-// Lock is a small Redis lease. Value must uniquely identify the worker that
-// owns it; Release uses a Lua compare-and-delete to avoid deleting a renewed
-// lease owned by another worker.
-type Lock struct {
- client *redis.Redis
- key string
- owner string
- ttl time.Duration
-}
-
-func New(conf redis.RedisConf, key, owner string, ttl time.Duration) *Lock {
- return &Lock{client: redis.MustNewRedis(conf), key: key, owner: owner, ttl: ttl}
-}
-
-func (l *Lock) Acquire(ctx context.Context) (bool, error) {
- if l == nil || l.client == nil || l.key == "" || l.owner == "" {
- return false, fmt.Errorf("invalid redis lock")
- }
- seconds := int(l.ttl.Seconds())
- if seconds < 1 {
- seconds = 1
- }
- return l.client.SetnxExCtx(ctx, l.key, l.owner, seconds)
-}
-
-func (l *Lock) Release(ctx context.Context) error {
- if l == nil || l.client == nil {
- return fmt.Errorf("invalid redis lock")
- }
- _, err := l.client.EvalCtx(ctx, `
+const (
+ renewScript = `
+if redis.call('GET', KEYS[1]) == ARGV[1] then
+ return redis.call('PEXPIRE', KEYS[1], ARGV[2])
+end
+return 0
+`
+ releaseScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
-`, []string{l.key}, l.owner)
+`
+)
+
+// Client is the subset of Redis used by Lock.
+type Client interface {
+ SetnxExCtx(ctx context.Context, key, value string, seconds int) (bool, error)
+ EvalCtx(ctx context.Context, script string, keys []string, args ...any) (any, error)
+}
+
+// Lock is a small Redis lease. Each acquisition gets a unique token so an old
+// owner cannot renew or release a successor's lease.
+type Lock struct {
+ client Client
+ key string
+ prefix string
+ ttl time.Duration
+
+ mu sync.RWMutex
+ token string
+}
+
+func New(conf redis.RedisConf, key, ownerPrefix string, ttl time.Duration) *Lock {
+ return NewWithClient(redis.MustNewRedis(conf), key, ownerPrefix, ttl)
+}
+
+func NewWithClient(client Client, key, ownerPrefix string, ttl time.Duration) *Lock {
+ return &Lock{client: client, key: key, prefix: strings.TrimSpace(ownerPrefix), ttl: ttl}
+}
+
+func (l *Lock) Acquire(ctx context.Context) (bool, error) {
+ if err := l.validate(); err != nil {
+ return false, err
+ }
+ token, err := newToken(l.prefix)
+ if err != nil {
+ return false, err
+ }
+ acquired, err := l.client.SetnxExCtx(ctx, l.key, token, durationSeconds(l.ttl))
+ if err != nil || !acquired {
+ return acquired, err
+ }
+ l.mu.Lock()
+ l.token = token
+ l.mu.Unlock()
+ return true, nil
+}
+
+// Renew extends the lease only while this acquisition still owns it.
+func (l *Lock) Renew(ctx context.Context) (bool, error) {
+ if err := l.validate(); err != nil {
+ return false, err
+ }
+ token := l.currentToken()
+ if token == "" {
+ return false, nil
+ }
+ ttlMilliseconds := l.ttl.Milliseconds()
+ if ttlMilliseconds < 1 {
+ ttlMilliseconds = 1
+ }
+ result, err := l.client.EvalCtx(ctx, renewScript, []string{l.key}, token, ttlMilliseconds)
+ if err != nil {
+ return false, err
+ }
+ return resultIsOne(result), nil
+}
+
+func (l *Lock) Release(ctx context.Context) error {
+ if err := l.validate(); err != nil {
+ return err
+ }
+ token := l.currentToken()
+ if token == "" {
+ return nil
+ }
+ result, err := l.client.EvalCtx(ctx, releaseScript, []string{l.key}, token)
+ if err == nil && resultIsOne(result) {
+ l.mu.Lock()
+ if l.token == token {
+ l.token = ""
+ }
+ l.mu.Unlock()
+ }
return err
}
+
+func (l *Lock) validate() error {
+ if l == nil || l.client == nil || l.key == "" || l.prefix == "" || l.ttl <= 0 {
+ return fmt.Errorf("invalid redis lock")
+ }
+ return nil
+}
+
+func (l *Lock) currentToken() string {
+ l.mu.RLock()
+ defer l.mu.RUnlock()
+ return l.token
+}
+
+func newToken(prefix string) (string, error) {
+ random := make([]byte, 16)
+ if _, err := rand.Read(random); err != nil {
+ return "", fmt.Errorf("generate redis lock token: %w", err)
+ }
+ return prefix + ":" + hex.EncodeToString(random), nil
+}
+
+func durationSeconds(ttl time.Duration) int {
+ seconds := int(ttl / time.Second)
+ if ttl%time.Second != 0 {
+ seconds++
+ }
+ if seconds < 1 {
+ seconds = 1
+ }
+ return seconds
+}
+
+func resultIsOne(result any) bool {
+ switch value := result.(type) {
+ case int64:
+ return value == 1
+ case int:
+ return value == 1
+ case uint64:
+ return value == 1
+ default:
+ return false
+ }
+}
diff --git a/apps/backend/internal/lib/redislock/redislock_test.go b/apps/backend/internal/lib/redislock/redislock_test.go
new file mode 100644
index 0000000..6656458
--- /dev/null
+++ b/apps/backend/internal/lib/redislock/redislock_test.go
@@ -0,0 +1,105 @@
+package redislock
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+type fakeClient struct {
+ value string
+ ttlMS int64
+}
+
+func (f *fakeClient) SetnxExCtx(ctx context.Context, key, value string, seconds int) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ if f.value != "" {
+ return false, nil
+ }
+ f.value = value
+ f.ttlMS = int64(seconds) * 1000
+ return true, nil
+}
+
+func (f *fakeClient) EvalCtx(ctx context.Context, script string, keys []string, args ...any) (any, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ owner := fmt.Sprint(args[0])
+ if f.value != owner {
+ return int64(0), nil
+ }
+ if script == renewScript {
+ f.ttlMS = args[1].(int64)
+ return int64(1), nil
+ }
+ if script == releaseScript {
+ f.value = ""
+ return int64(1), nil
+ }
+ return nil, fmt.Errorf("unexpected script")
+}
+
+func TestLockUsesUniqueTokenPerAcquisition(t *testing.T) {
+ client := &fakeClient{}
+ lock := NewWithClient(client, "locks:outbox", "worker-7", time.Minute)
+
+ acquired, err := lock.Acquire(context.Background())
+ if err != nil || !acquired {
+ t.Fatalf("Acquire() = %v, %v", acquired, err)
+ }
+ first := client.value
+ if !strings.HasPrefix(first, "worker-7:") {
+ t.Fatalf("token %q has no worker prefix", first)
+ }
+ if err := lock.Release(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ acquired, err = lock.Acquire(context.Background())
+ if err != nil || !acquired {
+ t.Fatalf("second Acquire() = %v, %v", acquired, err)
+ }
+ if client.value == first {
+ t.Fatal("successful acquisitions reused a token")
+ }
+}
+
+func TestRenewAndReleaseRequireCurrentOwnership(t *testing.T) {
+ client := &fakeClient{}
+ lock := NewWithClient(client, "locks:outbox", "worker-7", 3*time.Minute)
+ if acquired, err := lock.Acquire(context.Background()); err != nil || !acquired {
+ t.Fatalf("Acquire() = %v, %v", acquired, err)
+ }
+
+ client.ttlMS = 1
+ renewed, err := lock.Renew(context.Background())
+ if err != nil || !renewed || client.ttlMS != int64(3*time.Minute/time.Millisecond) {
+ t.Fatalf("Renew() = %v, %v, ttl=%d", renewed, err, client.ttlMS)
+ }
+
+ client.value = "worker-8:successor"
+ renewed, err = lock.Renew(context.Background())
+ if err != nil || renewed {
+ t.Fatalf("lost-owner Renew() = %v, %v", renewed, err)
+ }
+ if err := lock.Release(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if client.value != "worker-8:successor" {
+ t.Fatalf("Release removed successor lease: %q", client.value)
+ }
+}
+
+func TestLockMethodsHonorCanceledContext(t *testing.T) {
+ client := &fakeClient{}
+ lock := NewWithClient(client, "locks:outbox", "worker-7", time.Minute)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := lock.Acquire(ctx); err == nil {
+ t.Fatal("Acquire succeeded with canceled context")
+ }
+}
diff --git a/apps/backend/internal/lib/secretbox/secretbox.go b/apps/backend/internal/lib/secretbox/secretbox.go
new file mode 100644
index 0000000..997b28d
--- /dev/null
+++ b/apps/backend/internal/lib/secretbox/secretbox.go
@@ -0,0 +1,121 @@
+// Package secretbox encrypts member-owned secrets for persistence.
+package secretbox
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+const prefix = "enc:v1:"
+
+var (
+ ErrInvalidConfig = errors.New("invalid secretbox configuration")
+ ErrInvalidCiphertext = errors.New("invalid encrypted secret")
+ ErrUnsupportedVersion = errors.New("unsupported encrypted secret version")
+)
+
+// Codec is an AES-256-GCM codec for the current encryption key.
+type Codec struct {
+ keyID string
+ aead cipher.AEAD
+ rand io.Reader
+}
+
+// New constructs a codec from a key ID and a base64-encoded 32-byte key.
+func New(keyID, encodedKey string) (*Codec, error) {
+ keyID = strings.TrimSpace(keyID)
+ encodedKey = strings.TrimSpace(encodedKey)
+ if keyID == "" || strings.Contains(keyID, ":") || encodedKey == "" {
+ return nil, fmt.Errorf("%w: key ID and key are required", ErrInvalidConfig)
+ }
+ key, err := base64.StdEncoding.DecodeString(encodedKey)
+ if err != nil {
+ return nil, fmt.Errorf("%w: key must be base64: %v", ErrInvalidConfig, err)
+ }
+ if len(key) != 32 {
+ return nil, fmt.Errorf("%w: decoded key must be 32 bytes", ErrInvalidConfig)
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err)
+ }
+ return &Codec{keyID: keyID, aead: aead, rand: rand.Reader}, nil
+}
+
+// Encrypt encrypts plaintext and binds it to its member, field, and provider.
+func (c *Codec) Encrypt(uid int64, field, provider, plaintext string) (string, error) {
+ if c == nil || c.aead == nil {
+ return "", ErrInvalidConfig
+ }
+ nonce := make([]byte, c.aead.NonceSize())
+ if _, err := io.ReadFull(c.rand, nonce); err != nil {
+ return "", fmt.Errorf("generate encryption nonce: %w", err)
+ }
+ sealed := c.aead.Seal(nil, nonce, []byte(plaintext), additionalData(uid, field, provider))
+ payload := append(nonce, sealed...)
+ return prefix + c.keyID + ":" + base64.RawURLEncoding.EncodeToString(payload), nil
+}
+
+// Decrypt decrypts encrypted values. Unprefixed values are legacy plaintext.
+func (c *Codec) Decrypt(uid int64, field, provider, value string) (string, error) {
+ if !strings.HasPrefix(value, "enc:") {
+ return value, nil
+ }
+ if !strings.HasPrefix(value, prefix) {
+ return "", ErrUnsupportedVersion
+ }
+ if c == nil || c.aead == nil {
+ return "", ErrInvalidConfig
+ }
+ rest := strings.TrimPrefix(value, prefix)
+ keyID, payloadText, ok := strings.Cut(rest, ":")
+ if !ok || keyID == "" || payloadText == "" {
+ return "", ErrInvalidCiphertext
+ }
+ if keyID != c.keyID {
+ return "", fmt.Errorf("%w: unknown key ID %q", ErrInvalidCiphertext, keyID)
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(payloadText)
+ if err != nil || len(payload) < c.aead.NonceSize()+c.aead.Overhead() {
+ return "", ErrInvalidCiphertext
+ }
+ nonce := payload[:c.aead.NonceSize()]
+ ciphertext := payload[c.aead.NonceSize():]
+ plaintext, err := c.aead.Open(nil, nonce, ciphertext, additionalData(uid, field, provider))
+ if err != nil {
+ return "", fmt.Errorf("%w: authentication failed", ErrInvalidCiphertext)
+ }
+ return string(plaintext), nil
+}
+
+// IsEncrypted reports whether a value uses the encrypted-value namespace.
+func IsEncrypted(value string) bool {
+ return strings.HasPrefix(value, "enc:")
+}
+
+func additionalData(uid int64, field, provider string) []byte {
+ fieldBytes := []byte(field)
+ providerBytes := []byte(provider)
+ data := make([]byte, 8+4+len(fieldBytes)+4+len(providerBytes))
+ binary.BigEndian.PutUint64(data, uint64(uid))
+ offset := 8
+ binary.BigEndian.PutUint32(data[offset:], uint32(len(fieldBytes)))
+ offset += 4
+ copy(data[offset:], fieldBytes)
+ offset += len(fieldBytes)
+ binary.BigEndian.PutUint32(data[offset:], uint32(len(providerBytes)))
+ offset += 4
+ copy(data[offset:], providerBytes)
+ return data
+}
diff --git a/apps/backend/internal/lib/secretbox/secretbox_test.go b/apps/backend/internal/lib/secretbox/secretbox_test.go
new file mode 100644
index 0000000..f82325f
--- /dev/null
+++ b/apps/backend/internal/lib/secretbox/secretbox_test.go
@@ -0,0 +1,89 @@
+package secretbox
+
+import (
+ "encoding/base64"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func testCodec(t *testing.T) *Codec {
+ t.Helper()
+ c, err := New("test-key", base64.StdEncoding.EncodeToString(make([]byte, 32)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+func TestCodecRoundTripUsesRandomNonce(t *testing.T) {
+ c := testCodec(t)
+ one, err := c.Encrypt(42, "api_key", "xai", "top-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ two, err := c.Encrypt(42, "api_key", "xai", "top-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if one == two {
+ t.Fatal("ciphertexts must differ")
+ }
+ if !strings.HasPrefix(one, "enc:v1:test-key:") {
+ t.Fatalf("unexpected prefix: %q", one)
+ }
+ got, err := c.Decrypt(42, "api_key", "xai", one)
+ if err != nil || got != "top-secret" {
+ t.Fatalf("round trip = %q, %v", got, err)
+ }
+}
+
+func TestCodecAADAndTamperingFail(t *testing.T) {
+ c := testCodec(t)
+ value, err := c.Encrypt(42, "api_key", "xai", "top-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parts := strings.Split(value, ":")
+ payload, err := base64.RawURLEncoding.DecodeString(parts[3])
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload[len(payload)-1] ^= 0x01
+ tampered := strings.Join([]string{parts[0], parts[1], parts[2], base64.RawURLEncoding.EncodeToString(payload)}, ":")
+ for _, tc := range []struct {
+ name string
+ uid int64
+ field string
+ provider string
+ value string
+ }{
+ {name: "uid", uid: 43, field: "api_key", provider: "xai", value: value},
+ {name: "field", uid: 42, field: "exa_api_key", provider: "xai", value: value},
+ {name: "provider", uid: 42, field: "api_key", provider: "other", value: value},
+ {name: "tamper", uid: 42, field: "api_key", provider: "xai", value: tampered},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := c.Decrypt(tc.uid, tc.field, tc.provider, tc.value); !errors.Is(err, ErrInvalidCiphertext) {
+ t.Fatalf("error = %v", err)
+ }
+ })
+ }
+}
+
+func TestCodecCompatibilityAndValidation(t *testing.T) {
+ c := testCodec(t)
+ got, err := c.Decrypt(1, "api_key", "xai", "legacy-plaintext")
+ if err != nil || got != "legacy-plaintext" {
+ t.Fatalf("legacy = %q, %v", got, err)
+ }
+ if _, err := c.Decrypt(1, "api_key", "xai", "enc:v2:key:data"); !errors.Is(err, ErrUnsupportedVersion) {
+ t.Fatalf("version error = %v", err)
+ }
+ if _, err := c.Decrypt(1, "api_key", "xai", "enc:v1:other:data"); !errors.Is(err, ErrInvalidCiphertext) {
+ t.Fatalf("key ID error = %v", err)
+ }
+ if _, err := New("key", base64.StdEncoding.EncodeToString(make([]byte, 31))); !errors.Is(err, ErrInvalidConfig) {
+ t.Fatalf("key size error = %v", err)
+ }
+}
diff --git a/apps/backend/internal/logic/admin/set_roles_logic.go b/apps/backend/internal/logic/admin/set_roles_logic.go
index 7163e9f..da875a4 100644
--- a/apps/backend/internal/logic/admin/set_roles_logic.go
+++ b/apps/backend/internal/logic/admin/set_roles_logic.go
@@ -37,7 +37,10 @@ func (l *SetRolesLogic) SetRoles(req *types.AdminRolesReq) (resp *types.MemberPu
}
}
if wasAdmin && !willAdmin {
- n, _ := l.svcCtx.Members.CountActiveAdmins(l.ctx)
+ n, countErr := l.svcCtx.Members.CountActiveAdmins(l.ctx)
+ if countErr != nil {
+ return nil, countErr
+ }
if n <= 1 {
return nil, domain.ErrLastAdmin
}
diff --git a/apps/backend/internal/logic/admin/set_status_logic.go b/apps/backend/internal/logic/admin/set_status_logic.go
index 1ff9db7..24f556a 100644
--- a/apps/backend/internal/logic/admin/set_status_logic.go
+++ b/apps/backend/internal/logic/admin/set_status_logic.go
@@ -4,7 +4,9 @@ import (
"context"
"strconv"
+ "apps/backend/internal/middleware"
"apps/backend/internal/module/member/domain"
+ "apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
@@ -22,6 +24,7 @@ func NewSetStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetStat
}
func (l *SetStatusLogic) SetStatus(req *types.AdminStatusReq) (resp *types.MemberPublic, err error) {
+ actor, _ := middleware.UIDFrom(l.ctx)
uid, err := strconv.ParseInt(req.Uid, 10, 64)
if err != nil {
return nil, err
@@ -30,7 +33,25 @@ func (l *SetStatusLogic) SetStatus(req *types.AdminStatusReq) (resp *types.Membe
switch status {
case domain.StatusActive, domain.StatusSuspended, domain.StatusUnverified, domain.StatusUninitialized:
default:
- return nil, domain.ErrNotFound // treat invalid as not found-ish; better weak password reuse? use ErrNotFound ok for now
+ return nil, response.Biz(400, 400001, "invalid member status")
+ }
+ if status != domain.StatusActive {
+ if uid == actor {
+ return nil, domain.ErrSelfSuspend
+ }
+ member, findErr := l.svcCtx.Members.FindByUID(l.ctx, uid)
+ if findErr != nil {
+ return nil, findErr
+ }
+ if member.IsAdmin() {
+ n, countErr := l.svcCtx.Members.CountActiveAdmins(l.ctx)
+ if countErr != nil {
+ return nil, countErr
+ }
+ if n <= 1 {
+ return nil, domain.ErrLastAdmin
+ }
+ }
}
m, err := l.svcCtx.Auth.UpdateStatus(l.ctx, uid, status)
if err != nil {
diff --git a/apps/backend/internal/logic/admin/set_suspended_logic.go b/apps/backend/internal/logic/admin/set_suspended_logic.go
index ed508cc..0ad8d85 100644
--- a/apps/backend/internal/logic/admin/set_suspended_logic.go
+++ b/apps/backend/internal/logic/admin/set_suspended_logic.go
@@ -36,7 +36,10 @@ func (l *SetSuspendedLogic) SetSuspended(req *types.AdminSuspendReq) (resp *type
return nil, err
}
if req.Suspended && m.IsAdmin() {
- n, _ := l.svcCtx.Members.CountActiveAdmins(l.ctx)
+ n, countErr := l.svcCtx.Members.CountActiveAdmins(l.ctx)
+ if countErr != nil {
+ return nil, countErr
+ }
if n <= 1 {
return nil, domain.ErrLastAdmin
}
diff --git a/apps/backend/internal/logic/auth/o_auth_callback_logic.go b/apps/backend/internal/logic/auth/o_auth_callback_logic.go
index fcb8cdb..5cf5ee0 100644
--- a/apps/backend/internal/logic/auth/o_auth_callback_logic.go
+++ b/apps/backend/internal/logic/auth/o_auth_callback_logic.go
@@ -30,10 +30,7 @@ func (l *OAuthCallbackLogic) OAuthCallback(req *types.AuthOAuthCallbackReq) (res
var subject, email, name string
switch provider {
case domain.PlatformGoogle:
- token := req.AccessToken
- if token == "" {
- token = req.IdToken
- }
+ token := strings.TrimSpace(req.IdToken)
if token == "" {
return nil, domain.ErrOAuthFailed
}
diff --git a/apps/backend/internal/logic/billing/create_billing_checkout_session_logic.go b/apps/backend/internal/logic/billing/create_billing_checkout_session_logic.go
new file mode 100644
index 0000000..c733144
--- /dev/null
+++ b/apps/backend/internal/logic/billing/create_billing_checkout_session_logic.go
@@ -0,0 +1,42 @@
+package billing
+
+import (
+ "context"
+
+ "apps/backend/internal/middleware"
+ billingModule "apps/backend/internal/module/billing"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateBillingCheckoutSessionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateBillingCheckoutSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateBillingCheckoutSessionLogic {
+ return &CreateBillingCheckoutSessionLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *CreateBillingCheckoutSessionLogic) CreateBillingCheckoutSession(req *types.BillingCheckoutCreateReq) (resp *types.BillingCheckoutData, err error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok {
+ return nil, billingModule.ErrInvalidRequest
+ }
+ a, err := l.svcCtx.Billing.CreateCheckout(l.ctx, uid, req.PlanId, req.RequestId)
+ if err != nil {
+ return nil, err
+ }
+ return checkoutData(a), nil
+}
+
+func checkoutData(a *billingModule.CheckoutAttempt) *types.BillingCheckoutData {
+ return &types.BillingCheckoutData{Id: a.ID, SessionId: a.SessionID, Url: a.URL, ExpiresAt: a.ExpiresAt, Status: a.Status, PaymentStatus: a.PaymentStatus, FulfillmentStatus: a.FulfillmentStatus, PlanId: a.PlanID}
+}
diff --git a/apps/backend/internal/logic/billing/create_billing_portal_session_logic.go b/apps/backend/internal/logic/billing/create_billing_portal_session_logic.go
new file mode 100644
index 0000000..8c6c2ce
--- /dev/null
+++ b/apps/backend/internal/logic/billing/create_billing_portal_session_logic.go
@@ -0,0 +1,38 @@
+package billing
+
+import (
+ "context"
+
+ "apps/backend/internal/middleware"
+ billingModule "apps/backend/internal/module/billing"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateBillingPortalSessionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewCreateBillingPortalSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateBillingPortalSessionLogic {
+ return &CreateBillingPortalSessionLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *CreateBillingPortalSessionLogic) CreateBillingPortalSession() (resp *types.BillingPortalData, err error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok {
+ return nil, billingModule.ErrInvalidRequest
+ }
+ url, err := l.svcCtx.Billing.Portal(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ return &types.BillingPortalData{Url: url}, nil
+}
diff --git a/apps/backend/internal/logic/billing/get_billing_checkout_session_logic.go b/apps/backend/internal/logic/billing/get_billing_checkout_session_logic.go
new file mode 100644
index 0000000..f7da157
--- /dev/null
+++ b/apps/backend/internal/logic/billing/get_billing_checkout_session_logic.go
@@ -0,0 +1,38 @@
+package billing
+
+import (
+ "context"
+
+ "apps/backend/internal/middleware"
+ billingModule "apps/backend/internal/module/billing"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetBillingCheckoutSessionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetBillingCheckoutSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBillingCheckoutSessionLogic {
+ return &GetBillingCheckoutSessionLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetBillingCheckoutSessionLogic) GetBillingCheckoutSession(req *types.BillingCheckoutGetReq) (resp *types.BillingCheckoutData, err error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok {
+ return nil, billingModule.ErrInvalidRequest
+ }
+ a, err := l.svcCtx.Billing.GetCheckout(l.ctx, uid, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return checkoutData(a), nil
+}
diff --git a/apps/backend/internal/logic/billing/get_billing_subscription_logic.go b/apps/backend/internal/logic/billing/get_billing_subscription_logic.go
new file mode 100644
index 0000000..4ccdbcd
--- /dev/null
+++ b/apps/backend/internal/logic/billing/get_billing_subscription_logic.go
@@ -0,0 +1,38 @@
+package billing
+
+import (
+ "context"
+
+ "apps/backend/internal/middleware"
+ billingModule "apps/backend/internal/module/billing"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetBillingSubscriptionLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetBillingSubscriptionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBillingSubscriptionLogic {
+ return &GetBillingSubscriptionLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetBillingSubscriptionLogic) GetBillingSubscription() (resp *types.BillingSubscriptionData, err error) {
+ uid, ok := middleware.UIDFrom(l.ctx)
+ if !ok {
+ return nil, billingModule.ErrInvalidRequest
+ }
+ s, err := l.svcCtx.Billing.Subscription(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ return &types.BillingSubscriptionData{PlanId: s.PlanID, PaidPlanId: s.PaidPlanID, Status: s.Status, CustomerId: s.CustomerID, SubscriptionId: s.SubscriptionID, CurrentPeriodStart: s.CurrentPeriodStart, CurrentPeriodEnd: s.CurrentPeriodEnd, CancelAtPeriodEnd: s.CancelAtPeriodEnd, AdminOverride: s.AdminOverride}, nil
+}
diff --git a/apps/backend/internal/logic/billing/handle_stripe_webhook_logic.go b/apps/backend/internal/logic/billing/handle_stripe_webhook_logic.go
new file mode 100644
index 0000000..fdd96fc
--- /dev/null
+++ b/apps/backend/internal/logic/billing/handle_stripe_webhook_logic.go
@@ -0,0 +1,32 @@
+package billing
+
+import (
+ "context"
+
+ "apps/backend/internal/middleware"
+ billingModule "apps/backend/internal/module/billing"
+ "apps/backend/internal/svc"
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type HandleStripeWebhookLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewHandleStripeWebhookLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HandleStripeWebhookLogic {
+ return &HandleStripeWebhookLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *HandleStripeWebhookLogic) HandleStripeWebhook() error {
+ payload, signature, ok := middleware.StripeWebhookPayload(l.ctx)
+ if !ok {
+ return billingModule.ErrInvalidSignature
+ }
+ return l.svcCtx.Billing.HandleWebhook(l.ctx, payload, signature)
+}
diff --git a/apps/backend/internal/logic/ping/health_logic.go b/apps/backend/internal/logic/ping/health_logic.go
index 8f9f238..eb86710 100644
--- a/apps/backend/internal/logic/ping/health_logic.go
+++ b/apps/backend/internal/logic/ping/health_logic.go
@@ -20,5 +20,9 @@ func NewHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HealthLogi
}
func (l *HealthLogic) Health() (resp *types.HealthData, err error) {
+ if err := redisHealth(l.ctx, l.svcCtx); err != nil {
+ l.Errorf("health redis check failed: %v", err)
+ return nil, err
+ }
return &types.HealthData{Status: "ok", Service: "haixun-gateway"}, nil
}
diff --git a/apps/backend/internal/logic/ping/ping_logic.go b/apps/backend/internal/logic/ping/ping_logic.go
index 8591ef3..edac495 100644
--- a/apps/backend/internal/logic/ping/ping_logic.go
+++ b/apps/backend/internal/logic/ping/ping_logic.go
@@ -2,7 +2,10 @@ package ping
import (
"context"
+ "net/http"
+ "time"
+ "apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
@@ -20,5 +23,21 @@ func NewPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PingLogic {
}
func (l *PingLogic) Ping() (resp *types.HealthData, err error) {
+ if err := redisHealth(l.ctx, l.svcCtx); err != nil {
+ l.Errorf("ping redis health failed: %v", err)
+ return nil, err
+ }
return &types.HealthData{Status: "ok", Service: "haixun-gateway"}, nil
}
+
+func redisHealth(ctx context.Context, svcCtx *svc.ServiceContext) error {
+ if svcCtx == nil || svcCtx.Redis == nil {
+ return response.Biz(http.StatusServiceUnavailable, 503001, "redis unavailable")
+ }
+ pingCtx, cancel := context.WithTimeout(ctx, time.Second)
+ defer cancel()
+ if !svcCtx.Redis.PingCtx(pingCtx) {
+ return response.Biz(http.StatusServiceUnavailable, 503001, "redis unavailable")
+ }
+ return nil
+}
diff --git a/apps/backend/internal/logic/settings/get_placement_logic.go b/apps/backend/internal/logic/settings/get_placement_logic.go
index 9daf402..dbc3b87 100644
--- a/apps/backend/internal/logic/settings/get_placement_logic.go
+++ b/apps/backend/internal/logic/settings/get_placement_logic.go
@@ -23,16 +23,19 @@ func NewGetPlacementLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetP
func (l *GetPlacementLogic) GetPlacement() (resp *types.PlacementSettingsData, err error) {
uid, _ := middleware.UIDFrom(l.ctx)
- st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ st, err := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
if st == nil {
st = domain.DefaultSettings(uid)
}
return &types.PlacementSettingsData{
- WebSearchProvider: "exa",
- ExpandStrategy: st.ExpandStrategy,
+ WebSearchProvider: "exa",
+ ExpandStrategy: st.ExpandStrategy,
BraveApiKeyConfigured: false,
- ExaApiKeyConfigured: st.ExaAPIKeyConfigured,
- DevModeEnabled: st.DevModeEnabled,
- PlatformKeyAvailable: l.svcCtx.Config.Platform.ExaKey != "",
+ ExaApiKeyConfigured: st.ExaAPIKeyConfigured,
+ DevModeEnabled: st.DevModeEnabled,
+ PlatformKeyAvailable: l.svcCtx.Config.Platform.ExaKey != "",
}, nil
}
diff --git a/apps/backend/internal/logic/settings/list_models_logic.go b/apps/backend/internal/logic/settings/list_models_logic.go
index d99e450..f54e29a 100644
--- a/apps/backend/internal/logic/settings/list_models_logic.go
+++ b/apps/backend/internal/logic/settings/list_models_logic.go
@@ -29,7 +29,10 @@ func (l *ListModelsLogic) ListModels(req *types.ListModelsReq) (*types.ListModel
provider = ai.ProviderXAI
}
uid, _ := middleware.UIDFrom(l.ctx)
- st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ st, err := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
if st == nil {
st = domain.DefaultSettings(uid)
}
diff --git a/apps/backend/internal/logic/settings/save_ai_logic.go b/apps/backend/internal/logic/settings/save_ai_logic.go
index 1fdcbe4..4fe4d59 100644
--- a/apps/backend/internal/logic/settings/save_ai_logic.go
+++ b/apps/backend/internal/logic/settings/save_ai_logic.go
@@ -24,7 +24,10 @@ func NewSaveAiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveAiLogi
func (l *SaveAiLogic) SaveAi(req *types.SaveAiReq) (*types.AiSettingsData, error) {
uid, _ := middleware.UIDFrom(l.ctx)
- st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ st, err := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
if st == nil {
st = domain.DefaultSettings(uid)
}
diff --git a/apps/backend/internal/logic/settings/save_placement_logic.go b/apps/backend/internal/logic/settings/save_placement_logic.go
index ed0d427..3a82ee2 100644
--- a/apps/backend/internal/logic/settings/save_placement_logic.go
+++ b/apps/backend/internal/logic/settings/save_placement_logic.go
@@ -23,7 +23,10 @@ func NewSavePlacementLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Sav
func (l *SavePlacementLogic) SavePlacement(req *types.SavePlacementReq) (resp *types.PlacementSettingsData, err error) {
uid, _ := middleware.UIDFrom(l.ctx)
- st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ st, err := l.svcCtx.Members.GetSettings(l.ctx, uid)
+ if err != nil {
+ return nil, err
+ }
if st == nil {
st = domain.DefaultSettings(uid)
}
@@ -41,6 +44,8 @@ func (l *SavePlacementLogic) SavePlacement(req *types.SavePlacementReq) (resp *t
st.ExaAPIKey = ""
st.ExaAPIKeyConfigured = false
}
- _ = l.svcCtx.Members.SaveSettings(l.ctx, uid, st)
+ if err := l.svcCtx.Members.SaveSettings(l.ctx, uid, st); err != nil {
+ return nil, err
+ }
return NewGetPlacementLogic(l.ctx, l.svcCtx).GetPlacement()
}
diff --git a/apps/backend/internal/logic/usage/get_tenant_usage_analytics_logic.go b/apps/backend/internal/logic/usage/get_tenant_usage_analytics_logic.go
new file mode 100644
index 0000000..fc46ab6
--- /dev/null
+++ b/apps/backend/internal/logic/usage/get_tenant_usage_analytics_logic.go
@@ -0,0 +1,90 @@
+package usage
+
+import (
+ "context"
+ "errors"
+
+ usageDomain "apps/backend/internal/module/usage/domain"
+ usageUC "apps/backend/internal/module/usage/usecase"
+ "apps/backend/internal/response"
+ "apps/backend/internal/svc"
+ "apps/backend/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetTenantUsageAnalyticsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewGetTenantUsageAnalyticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetTenantUsageAnalyticsLogic {
+ return &GetTenantUsageAnalyticsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *GetTenantUsageAnalyticsLogic) GetTenantUsageAnalytics(req *types.UsageTenantAnalyticsReq) (resp *types.UsageTenantAnalyticsData, err error) {
+ if l.svcCtx.Usage == nil || l.svcCtx.Members == nil {
+ return nil, response.Biz(503, 503001, "usage not configured")
+ }
+ query, err := usageUC.ParseAnalyticsQuery(req.Granularity, req.From, req.To)
+ if err != nil {
+ if errors.Is(err, usageDomain.ErrInvalidRange) {
+ return nil, response.Biz(400, 400001, err.Error())
+ }
+ return nil, err
+ }
+ memberList, err := l.svcCtx.Members.ListAllMembers(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ members := make([]usageDomain.AnalyticsMember, 0, len(memberList))
+ for _, member := range memberList {
+ members = append(members, usageDomain.AnalyticsMember{
+ UID: member.UID, Email: member.Email, DisplayName: member.DisplayName,
+ })
+ }
+ analytics, err := l.svcCtx.Usage.TenantAnalytics(l.ctx, query, members)
+ if err != nil {
+ return nil, err
+ }
+
+ byMeter := make(map[string]types.UsageMeterCount, len(analytics.ByMeter))
+ for meter, count := range analytics.ByMeter {
+ byMeter[meter] = types.UsageMeterCount{Count: count.Count, Credits: count.Credits}
+ }
+ byokMeter := make(map[string]types.UsageMeterCount, len(analytics.Byok.ByMeter))
+ for meter, count := range analytics.Byok.ByMeter {
+ byokMeter[meter] = types.UsageMeterCount{Count: count.Count, Credits: count.Credits}
+ }
+ series := make([]types.UsageTenantAnalyticsBucket, 0, len(analytics.Series))
+ for _, bucket := range analytics.Series {
+ series = append(series, types.UsageTenantAnalyticsBucket{
+ Key: bucket.Key, Label: bucket.Label, PurchasedCredits: bucket.PurchasedCredits,
+ ConsumedCredits: bucket.ConsumedCredits, AiCalls: bucket.AICalls,
+ SearchCalls: bucket.SearchCalls, ByokCallCount: bucket.ByokCallCount,
+ })
+ }
+ rows := make([]types.UsageTenantAnalyticsMember, 0, len(analytics.Members))
+ for _, member := range analytics.Members {
+ rows = append(rows, types.UsageTenantAnalyticsMember{
+ Uid: member.UID, Email: member.Email, DisplayName: member.DisplayName,
+ Unlimited: member.Unlimited, PlanId: member.PlanID,
+ PurchasedCredits: member.PurchasedCredits, TotalCredits: member.TotalCredits,
+ AiCalls: member.AICalls, SearchCalls: member.SearchCalls,
+ ByokCallCount: member.ByokCallCount, RemainingCredits: member.RemainingCredits, Pct: member.Pct,
+ })
+ }
+ return &types.UsageTenantAnalyticsData{
+ Granularity: analytics.Granularity, From: analytics.From, To: analytics.To,
+ RangeLabel: analytics.RangeLabel, PurchasedCredits: analytics.PurchasedCredits,
+ ConsumedCredits: analytics.ConsumedCredits, RemainingCredits: analytics.RemainingCredits,
+ Pct: analytics.Pct, AiCalls: analytics.AICalls, SearchCalls: analytics.SearchCalls,
+ ByMeter: byMeter, Byok: types.UsageByokBlock{CallCount: analytics.Byok.CallCount, ByMeter: byokMeter},
+ Series: series, Members: rows,
+ }, nil
+}
diff --git a/apps/backend/internal/middleware/m1_t119_auth_test.go b/apps/backend/internal/middleware/m1_t119_auth_test.go
index 4ffe948..ac8898d 100644
--- a/apps/backend/internal/middleware/m1_t119_auth_test.go
+++ b/apps/backend/internal/middleware/m1_t119_auth_test.go
@@ -62,12 +62,12 @@ func (m *mwRepo) DeleteIdentity(context.Context, string, string) error { return
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) SetCode(string, string, string, time.Duration) bool { return true }
+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
}
diff --git a/apps/backend/internal/middleware/striperawbody_middleware.go b/apps/backend/internal/middleware/striperawbody_middleware.go
new file mode 100644
index 0000000..c43a0fd
--- /dev/null
+++ b/apps/backend/internal/middleware/striperawbody_middleware.go
@@ -0,0 +1,40 @@
+package middleware
+
+import (
+ "context"
+ "io"
+ "net/http"
+
+ "apps/backend/internal/response"
+)
+
+const stripeWebhookMaxBytes = 1 << 20
+
+type stripeRawBodyKey struct{}
+type stripeSignatureKey struct{}
+
+type StripeRawBodyMiddleware struct {
+}
+
+func NewStripeRawBodyMiddleware() *StripeRawBodyMiddleware {
+ return &StripeRawBodyMiddleware{}
+}
+
+func (m *StripeRawBodyMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, stripeWebhookMaxBytes))
+ if err != nil {
+ response.Fail(w, http.StatusRequestEntityTooLarge, 413001, "webhook payload too large", nil)
+ return
+ }
+ ctx := context.WithValue(r.Context(), stripeRawBodyKey{}, body)
+ ctx = context.WithValue(ctx, stripeSignatureKey{}, r.Header.Get("Stripe-Signature"))
+ next(w, r.WithContext(ctx))
+ }
+}
+
+func StripeWebhookPayload(ctx context.Context) ([]byte, string, bool) {
+ payload, payloadOK := ctx.Value(stripeRawBodyKey{}).([]byte)
+ signature, signatureOK := ctx.Value(stripeSignatureKey{}).(string)
+ return payload, signature, payloadOK && signatureOK
+}
diff --git a/apps/backend/internal/middleware/striperawbody_middleware_test.go b/apps/backend/internal/middleware/striperawbody_middleware_test.go
new file mode 100644
index 0000000..9ce5106
--- /dev/null
+++ b/apps/backend/internal/middleware/striperawbody_middleware_test.go
@@ -0,0 +1,33 @@
+package middleware
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestStripeRawBodyPreservesPayloadAndSignature(t *testing.T) {
+ payload := []byte("{\n \"exact\": \"bytes\"\n}")
+ req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payload))
+ req.Header.Set("Stripe-Signature", "t=1,v1=abc")
+ rr := httptest.NewRecorder()
+ NewStripeRawBodyMiddleware().Handle(func(_ http.ResponseWriter, r *http.Request) {
+ got, sig, ok := StripeWebhookPayload(r.Context())
+ require.True(t, ok)
+ require.Equal(t, payload, got)
+ require.Equal(t, "t=1,v1=abc", sig)
+ })(rr, req)
+ require.Equal(t, http.StatusOK, rr.Code)
+}
+
+func TestStripeRawBodyRejectsOverOneMiB(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(make([]byte, stripeWebhookMaxBytes+1)))
+ rr := httptest.NewRecorder()
+ called := false
+ NewStripeRawBodyMiddleware().Handle(func(http.ResponseWriter, *http.Request) { called = true })(rr, req)
+ require.False(t, called)
+ require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
+}
diff --git a/apps/backend/internal/module/ai/models_cache_redis.go b/apps/backend/internal/module/ai/models_cache_redis.go
new file mode 100644
index 0000000..fc28464
--- /dev/null
+++ b/apps/backend/internal/module/ai/models_cache_redis.go
@@ -0,0 +1,83 @@
+package ai
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+const defaultRedisNamespace = "haixun:dev:v1"
+
+// RedisModelsCache shares model-list entries and refresh locks across processes.
+type RedisModelsCache struct {
+ client *redis.Redis
+ namespace string
+}
+
+func NewRedisModelsCache(conf redis.RedisConf, namespace string) *RedisModelsCache {
+ return &RedisModelsCache{client: redis.MustNewRedis(conf), namespace: normalizeRedisNamespace(namespace)}
+}
+
+func (c *RedisModelsCache) Get(ctx context.Context, key ModelsCacheKey) (ModelsCacheValue, bool, error) {
+ raw, err := c.client.GetCtx(ctx, c.valueKey(key))
+ if err != nil {
+ return ModelsCacheValue{}, false, err
+ }
+ if raw == "" {
+ return ModelsCacheValue{}, false, nil
+ }
+ var value ModelsCacheValue
+ if err := json.Unmarshal([]byte(raw), &value); err != nil {
+ return ModelsCacheValue{}, false, err
+ }
+ return value, true, nil
+}
+
+func (c *RedisModelsCache) Set(ctx context.Context, key ModelsCacheKey, value ModelsCacheValue, ttl time.Duration) error {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return err
+ }
+ return c.client.SetexCtx(ctx, c.valueKey(key), string(raw), durationSeconds(ttl))
+}
+
+func (c *RedisModelsCache) AcquireRefresh(ctx context.Context, key ModelsCacheKey, token string, ttl time.Duration) (bool, error) {
+ return c.client.SetnxExCtx(ctx, c.lockKey(key), token, durationSeconds(ttl))
+}
+
+func (c *RedisModelsCache) ReleaseRefresh(ctx context.Context, key ModelsCacheKey, token string) error {
+ _, err := c.client.EvalCtx(ctx, `
+if redis.call('GET', KEYS[1]) == ARGV[1] then
+ return redis.call('DEL', KEYS[1])
+end
+return 0
+`, []string{c.lockKey(key)}, token)
+ return err
+}
+
+func (c *RedisModelsCache) valueKey(key ModelsCacheKey) string {
+ return fmt.Sprintf("%s:ai:models:{%s:%s}:value", c.namespace, key.Provider, key.CredentialFingerprint)
+}
+
+func (c *RedisModelsCache) lockKey(key ModelsCacheKey) string {
+ return fmt.Sprintf("%s:ai:models:{%s:%s}:lock", c.namespace, key.Provider, key.CredentialFingerprint)
+}
+
+func normalizeRedisNamespace(namespace string) string {
+ if namespace = strings.Trim(strings.TrimSpace(namespace), ":"); namespace != "" {
+ return namespace
+ }
+ return defaultRedisNamespace
+}
+
+func durationSeconds(ttl time.Duration) int {
+ seconds := int(ttl.Seconds())
+ if seconds < 1 {
+ return 1
+ }
+ return seconds
+}
diff --git a/apps/backend/internal/module/ai/models_cache_redis_test.go b/apps/backend/internal/module/ai/models_cache_redis_test.go
new file mode 100644
index 0000000..b756272
--- /dev/null
+++ b/apps/backend/internal/module/ai/models_cache_redis_test.go
@@ -0,0 +1,27 @@
+package ai
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRedisModelsCacheKeysAreNamespacedAndCoLocated(t *testing.T) {
+ cache := &RedisModelsCache{namespace: normalizeRedisNamespace("tenant:v2")}
+ key := ModelsCacheKey{Provider: ProviderXAI, CredentialFingerprint: strings.Repeat("a", 64)}
+ valueKey := cache.valueKey(key)
+ lockKey := cache.lockKey(key)
+
+ wantTag := "{" + ProviderXAI + ":" + strings.Repeat("a", 64) + "}"
+ if !strings.HasPrefix(valueKey, "tenant:v2:ai:models:") || !strings.Contains(valueKey, wantTag) {
+ t.Fatalf("unexpected value key %q", valueKey)
+ }
+ if !strings.Contains(lockKey, wantTag) {
+ t.Fatalf("lock key is not colocated with value key: %q", lockKey)
+ }
+}
+
+func TestRedisModelsCacheDefaultNamespace(t *testing.T) {
+ if got := normalizeRedisNamespace(""); got != "haixun:dev:v1" {
+ t.Fatalf("namespace=%q", got)
+ }
+}
diff --git a/apps/backend/internal/module/ai/registry.go b/apps/backend/internal/module/ai/registry.go
index 17c78ed..dbce1e5 100644
--- a/apps/backend/internal/module/ai/registry.go
+++ b/apps/backend/internal/module/ai/registry.go
@@ -2,11 +2,12 @@ package ai
import (
"context"
+ "crypto/hmac"
+ "crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
- "sync"
"time"
)
@@ -14,30 +15,61 @@ const (
ProviderXAI = "xai"
ProviderOpenCodeGo = "opencode-go"
- // ModelsCacheTTL — list models cache window
+ // ModelsCacheTTL is the fresh model-list window.
ModelsCacheTTL = 5 * time.Minute
+ // ModelsCacheStaleTTL is the maximum stale fallback window.
+ ModelsCacheStaleTTL = 15 * time.Minute
+
+ modelsRefreshLockTTL = 30 * time.Second
+ modelsRefreshWait = 25 * time.Millisecond
+ modelsRefreshChecks = 20
)
-// Registry holds provider clients + models cache.
+// ModelsCacheKey identifies one provider credential without containing the credential.
+type ModelsCacheKey struct {
+ Provider string
+ CredentialFingerprint string
+}
+
+// ModelsCacheValue is the JSON payload persisted by a ModelsCache.
+type ModelsCacheValue struct {
+ Models []string `json:"models"`
+ FetchedAt int64 `json:"fetchedAt"`
+}
+
+// ModelsCache stores model lists and coordinates refreshes across instances.
+type ModelsCache interface {
+ Get(ctx context.Context, key ModelsCacheKey) (ModelsCacheValue, bool, error)
+ Set(ctx context.Context, key ModelsCacheKey, value ModelsCacheValue, ttl time.Duration) error
+ AcquireRefresh(ctx context.Context, key ModelsCacheKey, token string, ttl time.Duration) (bool, error)
+ ReleaseRefresh(ctx context.Context, key ModelsCacheKey, token string) error
+}
+
+// Registry holds the immutable provider client registry and a shared models cache.
type Registry struct {
- clients map[string]Client
- mu sync.Mutex
- cache map[string]modelsCacheEntry
+ clients map[string]Client
+ cache ModelsCache
+ fingerprintSecret []byte
}
-type modelsCacheEntry struct {
- Models []string
- ExpiresAt time.Time
+func NewRegistry(cache ModelsCache, fingerprintSecret string) *Registry {
+ return newRegistry(cache, fingerprintSecret, map[string]Client{
+ ProviderXAI: NewOpenAICompatible(ProviderXAI, "https://api.x.ai/v1"),
+ ProviderOpenCodeGo: NewOpenAICompatible(ProviderOpenCodeGo, "https://opencode.ai/zen/go/v1"),
+ })
}
-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{},
+// NewRegistryWithClients injects provider clients for focused tests.
+func NewRegistryWithClients(cache ModelsCache, fingerprintSecret string, clients map[string]Client) *Registry {
+ return newRegistry(cache, fingerprintSecret, clients)
+}
+
+func newRegistry(cache ModelsCache, fingerprintSecret string, clients map[string]Client) *Registry {
+ immutableClients := make(map[string]Client, len(clients))
+ for provider, client := range clients {
+ immutableClients[NormalizeProvider(provider)] = client
}
+ return &Registry{clients: immutableClients, cache: cache, fingerprintSecret: []byte(fingerprintSecret)}
}
func NormalizeProvider(p string) string {
@@ -60,41 +92,105 @@ func (r *Registry) Client(provider string) (Client, error) {
return c, nil
}
-// ListModelsCached fetches models via provider API; caches 5 minutes per provider+key.
+// ListModelsCached fetches models via provider API; caches per provider+credential.
// 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
+ if _, err := r.Client(id); err != nil {
+ return nil, false, err
+ }
+ key := r.cacheKey(id, apiKey)
+ now := time.Now().UTC()
+ stale, hasStale := r.cached(ctx, key, now)
+ if hasStale && cacheAge(now, stale.FetchedAt) <= ModelsCacheTTL {
+ return cloneModels(stale.Models), true, nil
+ }
+
+ token, tokenErr := refreshToken()
+ locked := false
+ if tokenErr != nil {
+ err = tokenErr
+ } else if r.cache != nil {
+ locked, err = r.cache.AcquireRefresh(ctx, key, token, modelsRefreshLockTTL)
+ if err != nil {
+ locked = false
+ }
+ }
+ if locked {
+ defer func() {
+ releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
+ defer cancel()
+ _ = r.cache.ReleaseRefresh(releaseCtx, key, token)
+ }()
+ } else if r.cache != nil && err == nil {
+ for range modelsRefreshChecks {
+ select {
+ case <-ctx.Done():
+ return nil, false, ctx.Err()
+ case <-time.After(modelsRefreshWait):
+ }
+ if current, ok := r.cached(ctx, key, time.Now().UTC()); ok && cacheAge(time.Now().UTC(), current.FetchedAt) <= ModelsCacheTTL {
+ return cloneModels(current.Models), 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 {
+ if hasStale {
+ return cloneModels(stale.Models), true, nil
+ }
return nil, false, err
}
- r.mu.Lock()
- r.cache[key] = modelsCacheEntry{
- Models: append([]string(nil), list...),
- ExpiresAt: time.Now().Add(ModelsCacheTTL),
+ if r.cache != nil {
+ _ = r.cache.Set(ctx, key, ModelsCacheValue{
+ Models: cloneModels(list),
+ FetchedAt: time.Now().UTC().UnixNano(),
+ }, ModelsCacheStaleTTL)
}
- 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])
+func (r *Registry) cached(ctx context.Context, key ModelsCacheKey, now time.Time) (ModelsCacheValue, bool) {
+ if r.cache == nil {
+ return ModelsCacheValue{}, false
+ }
+ value, ok, err := r.cache.Get(ctx, key)
+ if err != nil || !ok || value.FetchedAt <= 0 {
+ return ModelsCacheValue{}, false
+ }
+ if cacheAge(now, value.FetchedAt) > ModelsCacheStaleTTL {
+ return ModelsCacheValue{}, false
+ }
+ return value, true
+}
+
+func (r *Registry) cacheKey(provider, apiKey string) ModelsCacheKey {
+ mac := hmac.New(sha256.New, r.fingerprintSecret)
+ _, _ = mac.Write([]byte(apiKey))
+ return ModelsCacheKey{Provider: provider, CredentialFingerprint: hex.EncodeToString(mac.Sum(nil))}
+}
+
+func refreshToken() (string, error) {
+ var token [16]byte
+ if _, err := rand.Read(token[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(token[:]), nil
+}
+
+func cacheAge(now time.Time, fetchedAt int64) time.Duration {
+ age := now.Sub(time.Unix(0, fetchedAt).UTC())
+ if age < 0 {
+ return 0
+ }
+ return age
+}
+
+func cloneModels(models []string) []string {
+ return append([]string(nil), models...)
}
// FallbackModels when no key / provider error (never empty for UI).
diff --git a/apps/backend/internal/module/ai/registry_test.go b/apps/backend/internal/module/ai/registry_test.go
new file mode 100644
index 0000000..5afc619
--- /dev/null
+++ b/apps/backend/internal/module/ai/registry_test.go
@@ -0,0 +1,236 @@
+package ai
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+const testFingerprintSecret = "model-cache-test-fingerprint-secret-32-bytes"
+
+type fakeModelsCache struct {
+ mu sync.Mutex
+ values map[ModelsCacheKey]ModelsCacheValue
+ locks map[ModelsCacheKey]string
+ seen []ModelsCacheKey
+ getErr error
+ lockErr error
+}
+
+func newFakeModelsCache() *fakeModelsCache {
+ return &fakeModelsCache{values: make(map[ModelsCacheKey]ModelsCacheValue), locks: make(map[ModelsCacheKey]string)}
+}
+
+func (c *fakeModelsCache) Get(_ context.Context, key ModelsCacheKey) (ModelsCacheValue, bool, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.seen = append(c.seen, key)
+ if c.getErr != nil {
+ return ModelsCacheValue{}, false, c.getErr
+ }
+ value, ok := c.values[key]
+ value.Models = cloneModels(value.Models)
+ return value, ok, nil
+}
+
+func (c *fakeModelsCache) Set(_ context.Context, key ModelsCacheKey, value ModelsCacheValue, _ time.Duration) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ value.Models = cloneModels(value.Models)
+ c.values[key] = value
+ return nil
+}
+
+func (c *fakeModelsCache) AcquireRefresh(_ context.Context, key ModelsCacheKey, token string, _ time.Duration) (bool, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.lockErr != nil {
+ return false, c.lockErr
+ }
+ if _, exists := c.locks[key]; exists {
+ return false, nil
+ }
+ c.locks[key] = token
+ return true, nil
+}
+
+func (c *fakeModelsCache) ReleaseRefresh(_ context.Context, key ModelsCacheKey, token string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.locks[key] == token {
+ delete(c.locks, key)
+ }
+ return nil
+}
+
+type modelClient struct {
+ calls atomic.Int32
+ models []string
+ err error
+ started chan struct{}
+ release chan struct{}
+}
+
+func (c *modelClient) Complete(context.Context, string, string, string) (string, error) {
+ return "", nil
+}
+
+func (c *modelClient) CompleteStream(context.Context, string, string, string, func(string) error) (string, error) {
+ return "", nil
+}
+
+func (c *modelClient) ListModels(context.Context, string) ([]string, error) {
+ c.calls.Add(1)
+ if c.started != nil {
+ select {
+ case c.started <- struct{}{}:
+ default:
+ }
+ }
+ if c.release != nil {
+ <-c.release
+ }
+ return cloneModels(c.models), c.err
+}
+
+func TestRegistryListModelsCacheHit(t *testing.T) {
+ cache := newFakeModelsCache()
+ client := &modelClient{models: []string{"provider-model"}}
+ registry := NewRegistryWithClients(cache, testFingerprintSecret, map[string]Client{ProviderXAI: client})
+ cache.values[registry.cacheKey(ProviderXAI, "api-key")] = ModelsCacheValue{
+ Models: []string{"cached-model"}, FetchedAt: time.Now().UTC().UnixNano(),
+ }
+
+ models, fromCache, err := registry.ListModelsCached(context.Background(), ProviderXAI, "api-key")
+ if err != nil || !fromCache || fmt.Sprint(models) != "[cached-model]" {
+ t.Fatalf("unexpected result models=%v fromCache=%v err=%v", models, fromCache, err)
+ }
+ if client.calls.Load() != 0 {
+ t.Fatalf("provider called on cache hit")
+ }
+}
+
+func TestRegistrySeparatesProvidersAndCredentials(t *testing.T) {
+ cache := newFakeModelsCache()
+ xai := &modelClient{models: []string{"xai-model"}}
+ opencode := &modelClient{models: []string{"opencode-model"}}
+ registry := NewRegistryWithClients(cache, testFingerprintSecret, map[string]Client{
+ ProviderXAI: xai, ProviderOpenCodeGo: opencode,
+ })
+
+ for _, call := range []struct{ provider, key string }{
+ {ProviderXAI, "key-a"}, {ProviderXAI, "key-b"}, {ProviderOpenCodeGo, "key-a"},
+ } {
+ if _, _, err := registry.ListModelsCached(context.Background(), call.provider, call.key); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ cache.mu.Lock()
+ entries := len(cache.values)
+ cache.mu.Unlock()
+ if entries != 3 || xai.calls.Load() != 2 || opencode.calls.Load() != 1 {
+ t.Fatalf("separation failed entries=%d xai=%d opencode=%d", entries, xai.calls.Load(), opencode.calls.Load())
+ }
+}
+
+func TestRegistryReturnsStaleOnProviderFailure(t *testing.T) {
+ cache := newFakeModelsCache()
+ client := &modelClient{err: errors.New("provider unavailable")}
+ registry := NewRegistryWithClients(cache, testFingerprintSecret, map[string]Client{ProviderXAI: client})
+ cache.values[registry.cacheKey(ProviderXAI, "api-key")] = ModelsCacheValue{
+ Models: []string{"stale-model"}, FetchedAt: time.Now().UTC().Add(-10 * time.Minute).UnixNano(),
+ }
+
+ models, fromCache, err := registry.ListModelsCached(context.Background(), ProviderXAI, "api-key")
+ if err != nil || !fromCache || fmt.Sprint(models) != "[stale-model]" {
+ t.Fatalf("unexpected stale result models=%v fromCache=%v err=%v", models, fromCache, err)
+ }
+}
+
+func TestRegistryRedisFailureFallsOpenToProvider(t *testing.T) {
+ cache := newFakeModelsCache()
+ cache.getErr = errors.New("redis read failed")
+ cache.lockErr = errors.New("redis lock failed")
+ client := &modelClient{models: []string{"provider-model"}}
+ registry := NewRegistryWithClients(cache, testFingerprintSecret, map[string]Client{ProviderXAI: client})
+
+ models, fromCache, err := registry.ListModelsCached(context.Background(), ProviderXAI, "api-key")
+ if err != nil || fromCache || fmt.Sprint(models) != "[provider-model]" {
+ t.Fatalf("unexpected fail-open result models=%v fromCache=%v err=%v", models, fromCache, err)
+ }
+ if client.calls.Load() != 1 {
+ t.Fatalf("provider calls=%d, want 1", client.calls.Load())
+ }
+}
+
+func TestRegistryCoalescesConcurrentRefresh(t *testing.T) {
+ cache := newFakeModelsCache()
+ client := &modelClient{
+ models: []string{"fresh-model"}, started: make(chan struct{}, 1), release: make(chan struct{}),
+ }
+ registry := NewRegistryWithClients(cache, testFingerprintSecret, map[string]Client{ProviderXAI: client})
+ type result struct {
+ models []string
+ err error
+ }
+ results := make(chan result, 2)
+ call := func() {
+ models, _, err := registry.ListModelsCached(context.Background(), ProviderXAI, "api-key")
+ results <- result{models: models, err: err}
+ }
+ go call()
+ select {
+ case <-client.started:
+ case <-time.After(time.Second):
+ t.Fatal("provider did not start")
+ }
+ go call()
+ time.Sleep(50 * time.Millisecond)
+ close(client.release)
+
+ for range 2 {
+ select {
+ case got := <-results:
+ if got.err != nil || fmt.Sprint(got.models) != "[fresh-model]" {
+ t.Fatalf("unexpected result models=%v err=%v", got.models, got.err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("concurrent refresh timed out")
+ }
+ }
+ if client.calls.Load() != 1 {
+ t.Fatalf("provider calls=%d, want 1", client.calls.Load())
+ }
+}
+
+func TestRegistryCacheKeyDoesNotExposeAPIKey(t *testing.T) {
+ cache := newFakeModelsCache()
+ client := &modelClient{models: []string{"model"}}
+ registry := NewRegistryWithClients(cache, testFingerprintSecret, map[string]Client{ProviderXAI: client})
+ apiKey := "sk-plain-secret-must-never-appear"
+ if _, _, err := registry.ListModelsCached(context.Background(), ProviderXAI, apiKey); err != nil {
+ t.Fatal(err)
+ }
+
+ cache.mu.Lock()
+ seen := fmt.Sprintf("%+v %+v", cache.seen, cache.values)
+ var fingerprint string
+ for key := range cache.values {
+ fingerprint = key.CredentialFingerprint
+ }
+ cache.mu.Unlock()
+ if strings.Contains(seen, apiKey) {
+ t.Fatalf("cache metadata exposed API key: %s", seen)
+ }
+ if len(fingerprint) != sha256HexLength || strings.Contains(fingerprint, apiKey) {
+ t.Fatalf("invalid credential fingerprint %q", fingerprint)
+ }
+}
+
+const sha256HexLength = 64
diff --git a/apps/backend/internal/module/billing/domain.go b/apps/backend/internal/module/billing/domain.go
new file mode 100644
index 0000000..e415ce0
--- /dev/null
+++ b/apps/backend/internal/module/billing/domain.go
@@ -0,0 +1,148 @@
+package billing
+
+import (
+ "context"
+ "errors"
+)
+
+var (
+ ErrDisabled = errors.New("billing is not configured")
+ ErrInvalidPlan = errors.New("invalid billing plan")
+ ErrInvalidRequest = errors.New("invalid billing request")
+ ErrNotFound = errors.New("billing record not found")
+ ErrConflict = errors.New("billing request is already being created")
+ ErrInvalidSignature = errors.New("invalid stripe signature")
+ ErrCustomerRequired = errors.New("stripe customer is not available")
+)
+
+const (
+ PlanFree = "free"
+ PlanStarter = "starter"
+ PlanPro = "pro"
+)
+
+type CheckoutAttempt struct {
+ ID string `bson:"_id"`
+ UID int64 `bson:"uid"`
+ RequestID string `bson:"request_id"`
+ PlanID string `bson:"plan_id"`
+ PriceID string `bson:"price_id"`
+ SessionID string `bson:"session_id,omitempty"`
+ URL string `bson:"url,omitempty"`
+ ExpiresAt int64 `bson:"expires_at,omitempty"`
+ Status string `bson:"status"`
+ PaymentStatus string `bson:"payment_status,omitempty"`
+ FulfillmentStatus string `bson:"fulfillment_status"`
+ CustomerID string `bson:"customer_id,omitempty"`
+ SubscriptionID string `bson:"subscription_id,omitempty"`
+ Error string `bson:"error,omitempty"`
+ CreatedAt int64 `bson:"created_at"`
+ UpdatedAt int64 `bson:"updated_at"`
+}
+
+type SubscriptionState struct {
+ UID int64
+ PlanID string
+ PaidPlanID string
+ Status string
+ CustomerID string
+ SubscriptionID string
+ CurrentPeriodStart int64
+ CurrentPeriodEnd int64
+ CancelAtPeriodEnd bool
+ AdminOverride bool
+}
+
+type ProviderCheckout struct {
+ SessionID string
+ URL string
+ ExpiresAt int64
+ Status string
+ PaymentStatus string
+ PriceID string
+ CustomerID string
+ SubscriptionID string
+}
+
+type CheckoutParams struct {
+ UID int64
+ AttemptID string
+ PlanID string
+ PriceID string
+ CustomerID string
+ SuccessURL string
+ CancelURL string
+ IdempotencyKey string
+}
+
+type ProviderSubscription struct {
+ ID string
+ CustomerID string
+ Status string
+ PriceID string
+ CurrentPeriodStart int64
+ CurrentPeriodEnd int64
+ CancelAtPeriodEnd bool
+}
+
+type Event struct {
+ ID string
+ Type string
+ Created int64
+ ObjectID string
+ SubscriptionID string
+ CustomerID string
+ PriceID string
+ Status string
+ PeriodStart int64
+ PeriodEnd int64
+ CancelAtPeriodEnd bool
+}
+
+type EntitlementUpdate struct {
+ EventID string
+ EventCreatedAt int64
+ UID int64
+ PlanID string
+ PaidPlanID string
+ Status string
+ CustomerID string
+ SubscriptionID string
+ CurrentPeriodStart int64
+ CurrentPeriodEnd int64
+ CancelAtPeriodEnd bool
+}
+
+type PurchaseAudit struct {
+ ID string `bson:"_id"`
+ UID int64 `bson:"uid"`
+ PlanID string `bson:"plan_id"`
+ StripeEventID string `bson:"stripe_event_id"`
+ StripeSessionID string `bson:"stripe_session_id,omitempty"`
+ StripeCustomerID string `bson:"stripe_customer_id,omitempty"`
+ StripeSubscriptionID string `bson:"stripe_subscription_id,omitempty"`
+ Status string `bson:"status"`
+ CreatedAt int64 `bson:"created_at"`
+}
+
+type Provider interface {
+ CreateCheckout(context.Context, CheckoutParams) (*ProviderCheckout, error)
+ GetCheckout(context.Context, string) (*ProviderCheckout, error)
+ GetSubscription(context.Context, string) (*ProviderSubscription, error)
+ CreatePortal(context.Context, string, string) (string, error)
+ VerifyEvent([]byte, string, string) (*Event, error)
+}
+
+type Repository interface {
+ InsertCheckout(context.Context, *CheckoutAttempt) (bool, error)
+ GetCheckoutByRequest(context.Context, int64, string) (*CheckoutAttempt, error)
+ GetCheckout(context.Context, int64, string) (*CheckoutAttempt, error)
+ GetCheckoutBySession(context.Context, string) (*CheckoutAttempt, error)
+ UpdateCheckout(context.Context, *CheckoutAttempt) error
+ GetSubscription(context.Context, int64) (*SubscriptionState, error)
+ FindUIDByCustomer(context.Context, string) (int64, error)
+ ApplyEntitlement(context.Context, EntitlementUpdate) (bool, error)
+ WebhookProcessed(context.Context, string) (bool, error)
+ MarkWebhookProcessed(context.Context, string, string, int64) error
+ UpsertPurchase(context.Context, *PurchaseAudit) error
+}
diff --git a/apps/backend/internal/module/billing/mongo_repository.go b/apps/backend/internal/module/billing/mongo_repository.go
new file mode 100644
index 0000000..a8ea775
--- /dev/null
+++ b/apps/backend/internal/module/billing/mongo_repository.go
@@ -0,0 +1,144 @@
+package billing
+
+import (
+ "context"
+ "time"
+
+ libmongo "apps/backend/internal/lib/mongo"
+ usageDomain "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"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+type MongoRepository struct {
+ attempts *mon.Model
+ prefs *mon.Model
+ purchases *mon.Model
+ webhooks *mon.Model
+}
+
+func NewMongoRepository(uri, database string) *MongoRepository {
+ uri = libmongo.MustMongoURI(uri)
+ return &MongoRepository{
+ attempts: mon.MustNewModel(uri, database, "billing_checkout_attempts"),
+ prefs: mon.MustNewModel(uri, database, "usage_prefs"),
+ purchases: mon.MustNewModel(uri, database, "usage_purchases"),
+ webhooks: mon.MustNewModel(uri, database, "billing_webhook_events"),
+ }
+}
+
+func (r *MongoRepository) InsertCheckout(ctx context.Context, a *CheckoutAttempt) (bool, error) {
+ _, err := r.attempts.InsertOne(ctx, a)
+ if mongo.IsDuplicateKeyError(err) {
+ return false, nil
+ }
+ return err == nil, err
+}
+
+func (r *MongoRepository) GetCheckoutByRequest(ctx context.Context, uid int64, requestID string) (*CheckoutAttempt, error) {
+ return r.findAttempt(ctx, bson.M{"uid": uid, "request_id": requestID})
+}
+func (r *MongoRepository) GetCheckout(ctx context.Context, uid int64, id string) (*CheckoutAttempt, error) {
+ return r.findAttempt(ctx, bson.M{"_id": id, "uid": uid})
+}
+func (r *MongoRepository) GetCheckoutBySession(ctx context.Context, id string) (*CheckoutAttempt, error) {
+ return r.findAttempt(ctx, bson.M{"session_id": id})
+}
+func (r *MongoRepository) findAttempt(ctx context.Context, filter bson.M) (*CheckoutAttempt, error) {
+ var a CheckoutAttempt
+ if err := r.attempts.FindOne(ctx, &a, filter); err != nil {
+ if err == mon.ErrNotFound {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ return &a, nil
+}
+func (r *MongoRepository) UpdateCheckout(ctx context.Context, a *CheckoutAttempt) error {
+ _, err := r.attempts.ReplaceOne(ctx, bson.M{"_id": a.ID}, a)
+ return err
+}
+
+func (r *MongoRepository) GetSubscription(ctx context.Context, uid int64) (*SubscriptionState, error) {
+ var p usageDomain.MemberPrefs
+ if err := r.prefs.FindOne(ctx, &p, bson.M{"uid": uid}); err != nil {
+ if err == mon.ErrNotFound {
+ return &SubscriptionState{UID: uid, PlanID: PlanFree, Status: "none"}, nil
+ }
+ return nil, err
+ }
+ return &SubscriptionState{UID: uid, PlanID: p.PlanID, PaidPlanID: p.PaidPlanID, Status: p.SubscriptionStatus, CustomerID: p.StripeCustomerID, SubscriptionID: p.StripeSubscriptionID, CurrentPeriodStart: p.CurrentPeriodStart, CurrentPeriodEnd: p.CurrentPeriodEnd, CancelAtPeriodEnd: p.CancelAtPeriodEnd, AdminOverride: p.AdminOverride}, nil
+}
+
+func (r *MongoRepository) FindUIDByCustomer(ctx context.Context, customerID string) (int64, error) {
+ var p usageDomain.MemberPrefs
+ if customerID == "" {
+ return 0, ErrNotFound
+ }
+ if err := r.prefs.FindOne(ctx, &p, bson.M{"stripe_customer_id": customerID}); err != nil {
+ if err == mon.ErrNotFound {
+ a, attemptErr := r.findAttempt(ctx, bson.M{"customer_id": customerID})
+ if attemptErr != nil {
+ return 0, ErrNotFound
+ }
+ return a.UID, nil
+ }
+ return 0, err
+ }
+ return p.UID, nil
+}
+
+func (r *MongoRepository) ApplyEntitlement(ctx context.Context, u EntitlementUpdate) (bool, error) {
+ filter := bson.M{"uid": u.UID, "$or": bson.A{
+ bson.M{"billing_event_created_at": bson.M{"$exists": false}},
+ bson.M{"billing_event_created_at": bson.M{"$lt": u.EventCreatedAt}},
+ bson.M{"billing_event_created_at": u.EventCreatedAt, "billing_event_id": bson.M{"$lt": u.EventID}},
+ }}
+ pipeline := mongo.Pipeline{bson.D{{Key: "$set", Value: bson.D{
+ {Key: "uid", Value: u.UID},
+ {Key: "paid_plan_id", Value: u.PaidPlanID},
+ {Key: "subscription_status", Value: u.Status},
+ {Key: "stripe_customer_id", Value: u.CustomerID},
+ {Key: "stripe_subscription_id", Value: u.SubscriptionID},
+ {Key: "current_period_start", Value: u.CurrentPeriodStart},
+ {Key: "current_period_end", Value: u.CurrentPeriodEnd},
+ {Key: "cancel_at_period_end", Value: u.CancelAtPeriodEnd},
+ {Key: "billing_event_id", Value: u.EventID},
+ {Key: "billing_event_created_at", Value: u.EventCreatedAt},
+ {Key: "updated_at", Value: time.Now().UTC().UnixNano()},
+ {Key: "plan_id", Value: bson.D{{Key: "$cond", Value: bson.A{
+ bson.D{{Key: "$eq", Value: bson.A{bson.D{{Key: "$ifNull", Value: bson.A{"$admin_override", false}}}, true}}},
+ bson.D{{Key: "$ifNull", Value: bson.A{"$plan_id", PlanFree}}}, u.PlanID,
+ }}}},
+ }}}}
+ res, err := r.prefs.UpdateOne(ctx, filter, pipeline, options.Update().SetUpsert(true))
+ if err != nil {
+ if mongo.IsDuplicateKeyError(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ return res.MatchedCount > 0 || res.UpsertedCount > 0, nil
+}
+
+func (r *MongoRepository) WebhookProcessed(ctx context.Context, id string) (bool, error) {
+ var out bson.M
+ err := r.webhooks.FindOne(ctx, &out, bson.M{"_id": id})
+ if err == mon.ErrNotFound {
+ return false, nil
+ }
+ return err == nil, err
+}
+func (r *MongoRepository) MarkWebhookProcessed(ctx context.Context, id, eventType string, created int64) error {
+ _, err := r.webhooks.ReplaceOne(ctx, bson.M{"_id": id}, bson.M{"_id": id, "stripe_event_id": id, "type": eventType, "stripe_created_at": created * int64(time.Second), "processed_at": time.Now().UTC().UnixNano()}, options.Replace().SetUpsert(true))
+ return err
+}
+func (r *MongoRepository) UpsertPurchase(ctx context.Context, p *PurchaseAudit) error {
+ _, err := r.purchases.ReplaceOne(ctx, bson.M{"stripe_event_id": p.StripeEventID}, p, options.Replace().SetUpsert(true))
+ return err
+}
+
+var _ Repository = (*MongoRepository)(nil)
diff --git a/apps/backend/internal/module/billing/service.go b/apps/backend/internal/module/billing/service.go
new file mode 100644
index 0000000..6706f85
--- /dev/null
+++ b/apps/backend/internal/module/billing/service.go
@@ -0,0 +1,315 @@
+package billing
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Service struct {
+ Repo Repository
+ Provider Provider
+ Enabled bool
+ WebhookSecret string
+ StarterPriceID string
+ ProPriceID string
+ SuccessURL string
+ CancelURL string
+ PortalReturnURL string
+ Now func() time.Time
+}
+
+func (s *Service) available() error {
+ if s == nil || !s.Enabled || s.Repo == nil || s.Provider == nil || strings.TrimSpace(s.StarterPriceID) == "" || strings.TrimSpace(s.ProPriceID) == "" || strings.TrimSpace(s.SuccessURL) == "" || strings.TrimSpace(s.CancelURL) == "" || strings.TrimSpace(s.PortalReturnURL) == "" {
+ return ErrDisabled
+ }
+ return nil
+}
+
+func (s *Service) PriceForPlan(plan string) (string, error) {
+ switch strings.ToLower(strings.TrimSpace(plan)) {
+ case PlanStarter:
+ return s.StarterPriceID, nil
+ case PlanPro:
+ return s.ProPriceID, nil
+ default:
+ return "", ErrInvalidPlan
+ }
+}
+
+func (s *Service) PlanForPrice(price string) (string, error) {
+ switch price {
+ case s.StarterPriceID:
+ return PlanStarter, nil
+ case s.ProPriceID:
+ return PlanPro, nil
+ default:
+ return "", ErrInvalidPlan
+ }
+}
+
+func (s *Service) CreateCheckout(ctx context.Context, uid int64, planID, requestID string) (*CheckoutAttempt, error) {
+ if err := s.available(); err != nil {
+ return nil, err
+ }
+ requestID = strings.TrimSpace(requestID)
+ if uid <= 0 || requestID == "" || len(requestID) > 128 {
+ return nil, ErrInvalidRequest
+ }
+ priceID, err := s.PriceForPlan(planID)
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now().UTC()
+ if s.Now != nil {
+ now = s.Now().UTC()
+ }
+ a := &CheckoutAttempt{ID: uuid.NewString(), UID: uid, RequestID: requestID, PlanID: strings.ToLower(strings.TrimSpace(planID)), PriceID: priceID, Status: "creating", FulfillmentStatus: "pending", CreatedAt: now.UnixNano(), UpdatedAt: now.UnixNano()}
+ inserted, err := s.Repo.InsertCheckout(ctx, a)
+ if err != nil {
+ return nil, err
+ }
+ if !inserted {
+ existing, err := s.Repo.GetCheckoutByRequest(ctx, uid, requestID)
+ if err != nil {
+ return nil, err
+ }
+ if existing.PlanID != a.PlanID {
+ return nil, ErrConflict
+ }
+ if existing.SessionID != "" {
+ return existing, nil
+ }
+ a = existing
+ }
+ idempotencyKey := fmt.Sprintf("billing-checkout-%d-%s", uid, requestID)
+ state, err := s.Repo.GetSubscription(ctx, uid)
+ if err != nil {
+ return nil, err
+ }
+ successURL := replaceURLTokens(s.SuccessURL, a.ID, a.PlanID)
+ cancelURL := replaceURLTokens(s.CancelURL, a.ID, a.PlanID)
+ co, err := s.Provider.CreateCheckout(ctx, CheckoutParams{UID: uid, AttemptID: a.ID, PlanID: a.PlanID, PriceID: priceID, CustomerID: state.CustomerID, SuccessURL: successURL, CancelURL: cancelURL, IdempotencyKey: idempotencyKey})
+ if err != nil {
+ a.Status, a.Error, a.UpdatedAt = "failed", err.Error(), time.Now().UTC().UnixNano()
+ _ = s.Repo.UpdateCheckout(ctx, a)
+ return nil, err
+ }
+ s.applyCheckout(a, co)
+ if err := s.Repo.UpdateCheckout(ctx, a); err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+
+func (s *Service) GetCheckout(ctx context.Context, uid int64, id string) (*CheckoutAttempt, error) {
+ if err := s.available(); err != nil {
+ return nil, err
+ }
+ a, err := s.Repo.GetCheckout(ctx, uid, id)
+ if err != nil {
+ return nil, err
+ }
+ if a.SessionID == "" {
+ return a, nil
+ }
+ co, err := s.Provider.GetCheckout(ctx, a.SessionID)
+ if err != nil {
+ return nil, err
+ }
+ s.applyCheckout(a, co)
+ if co.PriceID != "" {
+ plan, mapErr := s.PlanForPrice(co.PriceID)
+ if mapErr != nil {
+ return nil, mapErr
+ }
+ a.PlanID = plan
+ }
+ _ = s.Repo.UpdateCheckout(ctx, a)
+ return a, nil
+}
+
+func (s *Service) applyCheckout(a *CheckoutAttempt, co *ProviderCheckout) {
+ a.SessionID, a.URL, a.ExpiresAt = co.SessionID, co.URL, co.ExpiresAt
+ a.Status, a.PaymentStatus = co.Status, co.PaymentStatus
+ a.CustomerID, a.SubscriptionID = co.CustomerID, co.SubscriptionID
+ if co.PriceID != "" {
+ a.PriceID = co.PriceID
+ }
+ a.UpdatedAt = time.Now().UTC().UnixNano()
+}
+
+func (s *Service) Subscription(ctx context.Context, uid int64) (*SubscriptionState, error) {
+ if s == nil || s.Repo == nil {
+ return nil, ErrDisabled
+ }
+ return s.Repo.GetSubscription(ctx, uid)
+}
+
+func (s *Service) Portal(ctx context.Context, uid int64) (string, error) {
+ if err := s.available(); err != nil {
+ return "", err
+ }
+ state, err := s.Repo.GetSubscription(ctx, uid)
+ if err != nil {
+ return "", err
+ }
+ if state.CustomerID == "" {
+ return "", ErrCustomerRequired
+ }
+ return s.Provider.CreatePortal(ctx, state.CustomerID, s.PortalReturnURL)
+}
+
+func (s *Service) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
+ if err := s.available(); err != nil {
+ return err
+ }
+ if strings.TrimSpace(s.WebhookSecret) == "" {
+ return ErrDisabled
+ }
+ e, err := s.Provider.VerifyEvent(payload, signature, s.WebhookSecret)
+ if err != nil {
+ return err
+ }
+ done, err := s.Repo.WebhookProcessed(ctx, e.ID)
+ if err != nil || done {
+ return err
+ }
+ if err := s.processEvent(ctx, e); err != nil {
+ return err
+ }
+ return s.Repo.MarkWebhookProcessed(ctx, e.ID, e.Type, e.Created)
+}
+
+func (s *Service) processEvent(ctx context.Context, e *Event) error {
+ switch e.Type {
+ case "checkout.session.expired", "checkout.session.async_payment_failed":
+ a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID)
+ if err != nil {
+ return err
+ }
+ a.Status = strings.TrimPrefix(e.Type, "checkout.session.")
+ a.FulfillmentStatus = "failed"
+ a.UpdatedAt = time.Now().UTC().UnixNano()
+ return s.Repo.UpdateCheckout(ctx, a)
+ case "checkout.session.completed", "checkout.session.async_payment_succeeded":
+ return s.fulfillCheckout(ctx, e)
+ case "customer.subscription.updated", "customer.subscription.deleted":
+ return s.applySubscriptionEvent(ctx, e)
+ case "invoice.paid":
+ return s.applyInvoiceEvent(ctx, e)
+ default:
+ return nil
+ }
+}
+
+func (s *Service) fulfillCheckout(ctx context.Context, e *Event) error {
+ a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID)
+ if err != nil {
+ return err
+ }
+ co, err := s.Provider.GetCheckout(ctx, e.ObjectID)
+ if err != nil {
+ return err
+ }
+ plan, err := s.PlanForPrice(co.PriceID)
+ if err != nil {
+ return err
+ }
+ if co.PaymentStatus != "paid" && co.PaymentStatus != "no_payment_required" {
+ s.applyCheckout(a, co)
+ a.FulfillmentStatus = "pending"
+ return s.Repo.UpdateCheckout(ctx, a)
+ }
+ sub, err := s.Provider.GetSubscription(ctx, co.SubscriptionID)
+ if err != nil {
+ return err
+ }
+ if sub.PriceID != co.PriceID {
+ return ErrInvalidPlan
+ }
+ s.applyCheckout(a, co)
+ if err := s.apply(ctx, e, a.UID, plan, sub, a.SessionID); err != nil {
+ return err
+ }
+ a.PlanID, a.FulfillmentStatus = plan, "fulfilled"
+ return s.Repo.UpdateCheckout(ctx, a)
+}
+
+func (s *Service) applySubscriptionEvent(ctx context.Context, e *Event) error {
+ uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID)
+ if err != nil {
+ return err
+ }
+ plan := PlanFree
+ if e.Type != "customer.subscription.deleted" {
+ plan, err = s.PlanForPrice(e.PriceID)
+ if err != nil {
+ return err
+ }
+ } else if e.PriceID != "" {
+ if mapped, mapErr := s.PlanForPrice(e.PriceID); mapErr == nil {
+ plan = mapped
+ }
+ } else if state, stateErr := s.Repo.GetSubscription(ctx, uid); stateErr == nil && state.PaidPlanID != "" {
+ plan = state.PaidPlanID
+ }
+ sub := &ProviderSubscription{ID: e.ObjectID, CustomerID: e.CustomerID, Status: e.Status, PriceID: e.PriceID, CurrentPeriodStart: e.PeriodStart, CurrentPeriodEnd: e.PeriodEnd, CancelAtPeriodEnd: e.CancelAtPeriodEnd}
+ if e.Type == "customer.subscription.deleted" {
+ sub.Status = "canceled"
+ }
+ return s.apply(ctx, e, uid, plan, sub, "")
+}
+
+func (s *Service) applyInvoiceEvent(ctx context.Context, e *Event) error {
+ uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID)
+ if err != nil {
+ return err
+ }
+ subscriptionID := e.SubscriptionID
+ if subscriptionID == "" {
+ state, stateErr := s.Repo.GetSubscription(ctx, uid)
+ if stateErr != nil {
+ return stateErr
+ }
+ subscriptionID = state.SubscriptionID
+ }
+ sub, err := s.Provider.GetSubscription(ctx, subscriptionID)
+ if err != nil {
+ return err
+ }
+ plan, err := s.PlanForPrice(sub.PriceID)
+ if err != nil {
+ return err
+ }
+ if sub.CustomerID == "" {
+ sub.CustomerID = e.CustomerID
+ }
+ sub.Status = "active"
+ return s.apply(ctx, e, uid, plan, sub, "")
+}
+
+func (s *Service) apply(ctx context.Context, e *Event, uid int64, plan string, sub *ProviderSubscription, sessionID string) error {
+ activePlan := plan
+ if sub.Status != "active" && sub.Status != "trialing" {
+ activePlan = PlanFree
+ }
+ _, err := s.Repo.ApplyEntitlement(ctx, EntitlementUpdate{EventID: e.ID, EventCreatedAt: e.Created * int64(time.Second), UID: uid, PlanID: activePlan, PaidPlanID: plan, Status: sub.Status, CustomerID: sub.CustomerID, SubscriptionID: sub.ID, CurrentPeriodStart: sub.CurrentPeriodStart * int64(time.Second), CurrentPeriodEnd: sub.CurrentPeriodEnd * int64(time.Second), CancelAtPeriodEnd: sub.CancelAtPeriodEnd})
+ if err != nil {
+ return err
+ }
+ // Audit upsert must still run when entitlement was already applied.
+ return s.Repo.UpsertPurchase(ctx, &PurchaseAudit{ID: e.ID, UID: uid, PlanID: plan, StripeEventID: e.ID, StripeSessionID: sessionID, StripeCustomerID: sub.CustomerID, StripeSubscriptionID: sub.ID, Status: sub.Status, CreatedAt: e.Created * int64(time.Second)})
+}
+
+func ParseUID(v string) (int64, error) { return strconv.ParseInt(v, 10, 64) }
+
+func replaceURLTokens(value, checkoutID, planID string) string {
+ value = strings.ReplaceAll(value, "{CHECKOUT_ID}", url.QueryEscape(checkoutID))
+ return strings.ReplaceAll(value, "{PLAN_ID}", url.QueryEscape(planID))
+}
diff --git a/apps/backend/internal/module/billing/service_test.go b/apps/backend/internal/module/billing/service_test.go
new file mode 100644
index 0000000..8cc759f
--- /dev/null
+++ b/apps/backend/internal/module/billing/service_test.go
@@ -0,0 +1,311 @@
+package billing
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type fakeProvider struct {
+ mu sync.Mutex
+ creates int
+ lastCheckout CheckoutParams
+ event *Event
+ checkout ProviderCheckout
+ subscription ProviderSubscription
+}
+
+func (p *fakeProvider) CreateCheckout(_ context.Context, input CheckoutParams) (*ProviderCheckout, error) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.creates++
+ p.lastCheckout = input
+ out := p.checkout
+ out.PriceID = input.PriceID
+ return &out, nil
+}
+func (p *fakeProvider) GetCheckout(context.Context, string) (*ProviderCheckout, error) {
+ out := p.checkout
+ return &out, nil
+}
+func (p *fakeProvider) GetSubscription(context.Context, string) (*ProviderSubscription, error) {
+ out := p.subscription
+ return &out, nil
+}
+func (p *fakeProvider) CreatePortal(context.Context, string, string) (string, error) {
+ return "https://portal.test", nil
+}
+func (p *fakeProvider) VerifyEvent([]byte, string, string) (*Event, error) {
+ if p.event == nil {
+ return nil, ErrInvalidSignature
+ }
+ out := *p.event
+ return &out, nil
+}
+
+type fakeRepo struct {
+ mu sync.Mutex
+ attempts map[string]*CheckoutAttempt
+ requests map[string]string
+ processed map[string]bool
+ state SubscriptionState
+ lastCreated int64
+ lastEvent string
+ purchases map[string]*PurchaseAudit
+ entitlementErr error
+}
+
+func newFakeRepo() *fakeRepo {
+ return &fakeRepo{attempts: map[string]*CheckoutAttempt{}, requests: map[string]string{}, processed: map[string]bool{}, purchases: map[string]*PurchaseAudit{}, state: SubscriptionState{PlanID: PlanFree}}
+}
+func (r *fakeRepo) InsertCheckout(_ context.Context, a *CheckoutAttempt) (bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ key := formatUID(a.UID) + ":" + a.RequestID
+ if _, ok := r.requests[key]; ok {
+ return false, nil
+ }
+ cp := *a
+ r.attempts[a.ID] = &cp
+ r.requests[key] = a.ID
+ return true, nil
+}
+func (r *fakeRepo) GetCheckoutByRequest(_ context.Context, uid int64, request string) (*CheckoutAttempt, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.copyAttempt(r.requests[formatUID(uid)+":"+request])
+}
+func (r *fakeRepo) GetCheckout(_ context.Context, uid int64, id string) (*CheckoutAttempt, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ a, err := r.copyAttempt(id)
+ if err != nil || a.UID != uid {
+ return nil, ErrNotFound
+ }
+ return a, nil
+}
+func (r *fakeRepo) GetCheckoutBySession(_ context.Context, session string) (*CheckoutAttempt, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ for _, a := range r.attempts {
+ if a.SessionID == session {
+ cp := *a
+ return &cp, nil
+ }
+ }
+ return nil, ErrNotFound
+}
+func (r *fakeRepo) copyAttempt(id string) (*CheckoutAttempt, error) {
+ a := r.attempts[id]
+ if a == nil {
+ return nil, ErrNotFound
+ }
+ cp := *a
+ return &cp, nil
+}
+func (r *fakeRepo) UpdateCheckout(_ context.Context, a *CheckoutAttempt) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ cp := *a
+ r.attempts[a.ID] = &cp
+ return nil
+}
+func (r *fakeRepo) GetSubscription(context.Context, int64) (*SubscriptionState, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ cp := r.state
+ return &cp, nil
+}
+func (r *fakeRepo) FindUIDByCustomer(_ context.Context, customer string) (int64, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.state.CustomerID == customer {
+ return r.state.UID, nil
+ }
+ for _, a := range r.attempts {
+ if a.CustomerID == customer {
+ return a.UID, nil
+ }
+ }
+ return 0, ErrNotFound
+}
+func (r *fakeRepo) ApplyEntitlement(_ context.Context, u EntitlementUpdate) (bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.entitlementErr != nil {
+ return false, r.entitlementErr
+ }
+ if u.EventCreatedAt < r.lastCreated || (u.EventCreatedAt == r.lastCreated && u.EventID <= r.lastEvent) {
+ return false, nil
+ }
+ r.lastCreated, r.lastEvent = u.EventCreatedAt, u.EventID
+ r.state.UID, r.state.Status, r.state.CustomerID, r.state.SubscriptionID = u.UID, u.Status, u.CustomerID, u.SubscriptionID
+ r.state.PaidPlanID = u.PaidPlanID
+ r.state.CurrentPeriodStart, r.state.CurrentPeriodEnd, r.state.CancelAtPeriodEnd = u.CurrentPeriodStart, u.CurrentPeriodEnd, u.CancelAtPeriodEnd
+ if !r.state.AdminOverride {
+ r.state.PlanID = u.PlanID
+ }
+ return true, nil
+}
+func (r *fakeRepo) WebhookProcessed(_ context.Context, id string) (bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.processed[id], nil
+}
+func (r *fakeRepo) MarkWebhookProcessed(_ context.Context, id, _ string, _ int64) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.processed[id] = true
+ return nil
+}
+func (r *fakeRepo) UpsertPurchase(_ context.Context, p *PurchaseAudit) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ cp := *p
+ r.purchases[p.StripeEventID] = &cp
+ return nil
+}
+
+func newService(repo Repository, provider Provider) *Service {
+ return &Service{Repo: repo, Provider: provider, Enabled: true, WebhookSecret: "whsec_test", StarterPriceID: "price_starter", ProPriceID: "price_pro", SuccessURL: "https://app/success", CancelURL: "https://app/cancel", PortalReturnURL: "https://app/billing"}
+}
+
+func TestPlanPriceMapping(t *testing.T) {
+ s := newService(newFakeRepo(), &fakeProvider{})
+ price, err := s.PriceForPlan(" STARTER ")
+ require.NoError(t, err)
+ require.Equal(t, "price_starter", price)
+ plan, err := s.PlanForPrice("price_pro")
+ require.NoError(t, err)
+ require.Equal(t, PlanPro, plan)
+ _, err = s.PriceForPlan(PlanFree)
+ require.ErrorIs(t, err, ErrInvalidPlan)
+ _, err = s.PlanForPrice("price_spoofed")
+ require.ErrorIs(t, err, ErrInvalidPlan)
+}
+
+func TestCreateCheckoutIsIdempotent(t *testing.T) {
+ r := newFakeRepo()
+ p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", URL: "https://checkout", ExpiresAt: 123}}
+ s := newService(r, p)
+ a, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request-1")
+ require.NoError(t, err)
+ b, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request-1")
+ require.NoError(t, err)
+ require.Equal(t, a.ID, b.ID)
+ require.Equal(t, 1, p.creates)
+ _, err = s.CreateCheckout(context.Background(), 7, PlanPro, "request-1")
+ require.ErrorIs(t, err, ErrConflict)
+}
+
+func TestCreateCheckoutInjectsReturnURLTokens(t *testing.T) {
+ r := newFakeRepo()
+ p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", URL: "https://checkout"}}
+ s := newService(r, p)
+ s.SuccessURL = "https://app.test/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}"
+ s.CancelURL = "https://app.test/app/usage/checkout?result=cancel&plan={PLAN_ID}"
+ a, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request with spaces")
+ require.NoError(t, err)
+ require.Contains(t, p.lastCheckout.SuccessURL, "checkout_id="+a.ID)
+ require.Contains(t, p.lastCheckout.SuccessURL, "plan=starter")
+ require.NotContains(t, p.lastCheckout.SuccessURL, "{")
+ require.Equal(t, "https://app.test/app/usage/checkout?result=cancel&plan=starter", p.lastCheckout.CancelURL)
+}
+
+func TestSubscriptionRemainsReadableWhenStripeDisabled(t *testing.T) {
+ r := newFakeRepo()
+ r.state = SubscriptionState{UID: 7, PlanID: PlanFree}
+ s := newService(r, &fakeProvider{})
+ s.Enabled = false
+ state, err := s.Subscription(context.Background(), 7)
+ require.NoError(t, err)
+ require.Equal(t, PlanFree, state.PlanID)
+}
+
+func TestWebhookDuplicateOutOfOrderDowngradeAndAdminOverride(t *testing.T) {
+ r := newFakeRepo()
+ r.state = SubscriptionState{UID: 7, PlanID: PlanFree, CustomerID: "cus_1"}
+ p := &fakeProvider{}
+ s := newService(r, p)
+ p.event = &Event{ID: "evt_new", Type: "customer.subscription.updated", Created: 20, ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("new"), "sig"))
+ require.Equal(t, PlanPro, r.state.PlanID)
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("duplicate"), "sig"))
+ require.Len(t, r.purchases, 1)
+ p.event = &Event{ID: "evt_old", Type: "customer.subscription.deleted", Created: 10, ObjectID: "sub_1", CustomerID: "cus_1", Status: "canceled"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("old"), "sig"))
+ require.Equal(t, PlanPro, r.state.PlanID)
+ p.event = &Event{ID: "evt_delete", Type: "customer.subscription.deleted", Created: 30, ObjectID: "sub_1", CustomerID: "cus_1", Status: "canceled"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("delete"), "sig"))
+ require.Equal(t, PlanFree, r.state.PlanID)
+ r.state.AdminOverride, r.state.PlanID = true, PlanStarter
+ p.event = &Event{ID: "evt_admin", Type: "customer.subscription.updated", Created: 40, ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("admin"), "sig"))
+ require.Equal(t, PlanStarter, r.state.PlanID)
+ require.Equal(t, "active", r.state.Status)
+}
+
+func TestPaidCheckoutActivationUsesRetrievedPrice(t *testing.T) {
+ r := newFakeRepo()
+ a := &CheckoutAttempt{ID: "attempt_1", UID: 9, RequestID: "r1", PlanID: PlanStarter, PriceID: "price_starter", SessionID: "cs_1", FulfillmentStatus: "pending"}
+ r.attempts[a.ID] = a
+ p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "paid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}, subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "active", PriceID: "price_pro", CurrentPeriodStart: 10, CurrentPeriodEnd: 20}}
+ s := newService(r, p)
+ p.event = &Event{ID: "evt_checkout", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("paid"), "sig"))
+ require.Equal(t, PlanPro, r.state.PlanID)
+ require.Equal(t, "fulfilled", r.attempts[a.ID].FulfillmentStatus)
+}
+
+func TestPaidCheckoutIsNotFulfilledWhenEntitlementWriteFails(t *testing.T) {
+ r := newFakeRepo()
+ r.entitlementErr = errors.New("entitlement unavailable")
+ a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"}
+ r.attempts[a.ID] = a
+ p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "paid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}, subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "active", PriceID: "price_pro"}}
+ s := newService(r, p)
+ p.event = &Event{ID: "evt_checkout", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"}
+
+ err := s.HandleWebhook(context.Background(), []byte("paid"), "sig")
+ require.ErrorContains(t, err, "entitlement unavailable")
+ require.Equal(t, "pending", r.attempts[a.ID].FulfillmentStatus)
+ require.False(t, r.processed["evt_checkout"])
+}
+
+func TestUnpaidCompletedCheckoutWaitsForAsyncSuccess(t *testing.T) {
+ r := newFakeRepo()
+ a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"}
+ r.attempts[a.ID] = a
+ p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "unpaid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}}
+ s := newService(r, p)
+ p.event = &Event{ID: "evt_unpaid", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("unpaid"), "sig"))
+ require.Equal(t, PlanFree, r.state.PlanID)
+ require.Empty(t, r.purchases)
+}
+
+func TestCheckoutFailureAndInvoicePaid(t *testing.T) {
+ r := newFakeRepo()
+ a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"}
+ r.attempts[a.ID] = a
+ r.state = SubscriptionState{UID: 9, PlanID: PlanStarter, CustomerID: "cus_9", SubscriptionID: "sub_9"}
+ p := &fakeProvider{subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "past_due", PriceID: "price_pro", CurrentPeriodStart: 10, CurrentPeriodEnd: 20}}
+ s := newService(r, p)
+ p.event = &Event{ID: "evt_failed", Type: "checkout.session.async_payment_failed", Created: 10, ObjectID: "cs_1"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("failed"), "sig"))
+ require.Equal(t, "failed", r.attempts[a.ID].FulfillmentStatus)
+
+ // The provider's current subscription price is authoritative, not an invoice metadata/line hint.
+ p.event = &Event{ID: "evt_invoice", Type: "invoice.paid", Created: 20, ObjectID: "in_1", SubscriptionID: "sub_9", CustomerID: "cus_9", PriceID: "price_starter"}
+ require.NoError(t, s.HandleWebhook(context.Background(), []byte("invoice"), "sig"))
+ require.Equal(t, PlanPro, r.state.PlanID)
+ require.Equal(t, "active", r.state.Status)
+}
+
+func TestDisabled(t *testing.T) {
+ _, err := (&Service{}).CreateCheckout(context.Background(), 1, PlanStarter, "x")
+ require.True(t, errors.Is(err, ErrDisabled))
+}
diff --git a/apps/backend/internal/module/billing/stripe_provider.go b/apps/backend/internal/module/billing/stripe_provider.go
new file mode 100644
index 0000000..441d0bf
--- /dev/null
+++ b/apps/backend/internal/module/billing/stripe_provider.go
@@ -0,0 +1,199 @@
+package billing
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "time"
+
+ "github.com/stripe/stripe-go/v82"
+ "github.com/stripe/stripe-go/v82/client"
+ "github.com/stripe/stripe-go/v82/webhook"
+)
+
+type StripeProvider struct{ api *client.API }
+
+func NewStripeProvider(secretKey string) *StripeProvider {
+ return &StripeProvider{api: client.New(secretKey, nil)}
+}
+
+func (p *StripeProvider) CreateCheckout(ctx context.Context, input CheckoutParams) (*ProviderCheckout, error) {
+ uidText := stripe.String(formatUID(input.UID))
+ params := &stripe.CheckoutSessionParams{
+ Params: stripe.Params{Context: ctx},
+ Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
+ SuccessURL: stripe.String(input.SuccessURL), CancelURL: stripe.String(input.CancelURL),
+ ClientReferenceID: uidText,
+ Metadata: map[string]string{"uid": formatUID(input.UID), "attempt_id": input.AttemptID, "plan_id": input.PlanID},
+ SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{Metadata: map[string]string{"uid": formatUID(input.UID), "attempt_id": input.AttemptID}},
+ LineItems: []*stripe.CheckoutSessionLineItemParams{{Price: stripe.String(input.PriceID), Quantity: stripe.Int64(1)}},
+ }
+ if input.CustomerID != "" {
+ params.Customer = stripe.String(input.CustomerID)
+ }
+ params.SetIdempotencyKey(input.IdempotencyKey)
+ session, err := p.api.CheckoutSessions.New(params)
+ if err != nil {
+ return nil, err
+ }
+ return checkoutFromStripe(session), nil
+}
+
+func (p *StripeProvider) GetCheckout(ctx context.Context, id string) (*ProviderCheckout, error) {
+ params := &stripe.CheckoutSessionParams{Params: stripe.Params{Context: ctx}}
+ params.AddExpand("line_items.data.price")
+ session, err := p.api.CheckoutSessions.Get(id, params)
+ if err != nil {
+ return nil, err
+ }
+ return checkoutFromStripe(session), nil
+}
+
+func checkoutFromStripe(s *stripe.CheckoutSession) *ProviderCheckout {
+ co := &ProviderCheckout{SessionID: s.ID, URL: s.URL, ExpiresAt: s.ExpiresAt * int64(time.Second), Status: string(s.Status), PaymentStatus: string(s.PaymentStatus)}
+ if s.Customer != nil {
+ co.CustomerID = s.Customer.ID
+ }
+ if s.Subscription != nil {
+ co.SubscriptionID = s.Subscription.ID
+ }
+ if s.LineItems != nil {
+ for _, item := range s.LineItems.Data {
+ if item != nil && item.Price != nil {
+ co.PriceID = item.Price.ID
+ break
+ }
+ }
+ }
+ return co
+}
+
+func (p *StripeProvider) GetSubscription(ctx context.Context, id string) (*ProviderSubscription, error) {
+ params := &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}}
+ params.AddExpand("items.data.price")
+ s, err := p.api.Subscriptions.Get(id, params)
+ if err != nil {
+ return nil, err
+ }
+ out := &ProviderSubscription{ID: s.ID, Status: string(s.Status), CancelAtPeriodEnd: s.CancelAtPeriodEnd}
+ if s.Customer != nil {
+ out.CustomerID = s.Customer.ID
+ }
+ if s.Items != nil {
+ for _, item := range s.Items.Data {
+ if item == nil {
+ continue
+ }
+ if item.Price != nil && out.PriceID == "" {
+ out.PriceID = item.Price.ID
+ }
+ if item.CurrentPeriodStart > out.CurrentPeriodStart {
+ out.CurrentPeriodStart = item.CurrentPeriodStart
+ }
+ if item.CurrentPeriodEnd > out.CurrentPeriodEnd {
+ out.CurrentPeriodEnd = item.CurrentPeriodEnd
+ }
+ }
+ }
+ return out, nil
+}
+
+func (p *StripeProvider) CreatePortal(ctx context.Context, customerID, returnURL string) (string, error) {
+ s, err := p.api.BillingPortalSessions.New(&stripe.BillingPortalSessionParams{Params: stripe.Params{Context: ctx}, Customer: stripe.String(customerID), ReturnURL: stripe.String(returnURL)})
+ if err != nil {
+ return "", err
+ }
+ return s.URL, nil
+}
+
+func (p *StripeProvider) VerifyEvent(payload []byte, signature, secret string) (*Event, error) {
+ e, err := webhook.ConstructEventWithOptions(payload, signature, secret, webhook.ConstructEventOptions{Tolerance: 5 * time.Minute})
+ if err != nil {
+ return nil, errors.Join(ErrInvalidSignature, err)
+ }
+ out := &Event{ID: e.ID, Type: string(e.Type), Created: e.Created}
+ var obj stripeObject
+ if err := json.Unmarshal(e.Data.Raw, &obj); err != nil {
+ return nil, err
+ }
+ out.ObjectID = obj.ID
+ out.CustomerID = objectID(obj.Customer)
+ out.Status = obj.Status
+ out.PeriodStart, out.PeriodEnd = obj.PeriodStart, obj.PeriodEnd
+ out.CancelAtPeriodEnd = obj.CancelAtPeriodEnd
+ out.SubscriptionID = objectID(obj.Subscription)
+ if out.SubscriptionID == "" {
+ out.SubscriptionID = objectID(obj.Parent.SubscriptionDetails.Subscription)
+ }
+ if len(obj.Items.Data) > 0 {
+ out.PriceID = objectID(obj.Items.Data[0].Price)
+ out.PeriodStart, out.PeriodEnd = obj.Items.Data[0].CurrentPeriodStart, obj.Items.Data[0].CurrentPeriodEnd
+ }
+ if len(obj.Lines.Data) > 0 {
+ line := obj.Lines.Data[0]
+ out.PriceID = objectID(line.Price)
+ if out.PriceID == "" {
+ out.PriceID = objectID(line.Pricing.PriceDetails.Price)
+ }
+ if line.Period.Start != 0 {
+ out.PeriodStart, out.PeriodEnd = line.Period.Start, line.Period.End
+ }
+ }
+ return out, nil
+}
+
+type stripeObject struct {
+ ID string `json:"id"`
+ Customer json.RawMessage `json:"customer"`
+ Subscription json.RawMessage `json:"subscription"`
+ Status string `json:"status"`
+ PeriodStart int64 `json:"period_start"`
+ PeriodEnd int64 `json:"period_end"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ Parent struct {
+ SubscriptionDetails struct {
+ Subscription json.RawMessage `json:"subscription"`
+ } `json:"subscription_details"`
+ } `json:"parent"`
+ Items struct {
+ Data []struct {
+ Price json.RawMessage `json:"price"`
+ CurrentPeriodStart int64 `json:"current_period_start"`
+ CurrentPeriodEnd int64 `json:"current_period_end"`
+ } `json:"data"`
+ } `json:"items"`
+ Lines struct {
+ Data []struct {
+ Price json.RawMessage `json:"price"`
+ Period struct {
+ Start int64 `json:"start"`
+ End int64 `json:"end"`
+ } `json:"period"`
+ Pricing struct {
+ PriceDetails struct {
+ Price json.RawMessage `json:"price"`
+ } `json:"price_details"`
+ } `json:"pricing"`
+ } `json:"data"`
+ } `json:"lines"`
+}
+
+func objectID(raw json.RawMessage) string {
+ if len(raw) == 0 || string(raw) == "null" {
+ return ""
+ }
+ var id string
+ if json.Unmarshal(raw, &id) == nil {
+ return id
+ }
+ var obj struct {
+ ID string `json:"id"`
+ }
+ _ = json.Unmarshal(raw, &obj)
+ return obj.ID
+}
+
+func formatUID(uid int64) string { return strconv.FormatInt(uid, 10) }
+
+var _ Provider = (*StripeProvider)(nil)
diff --git a/apps/backend/internal/module/billing/stripe_provider_test.go b/apps/backend/internal/module/billing/stripe_provider_test.go
new file mode 100644
index 0000000..798c657
--- /dev/null
+++ b/apps/backend/internal/module/billing/stripe_provider_test.go
@@ -0,0 +1,20 @@
+package billing
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "github.com/stripe/stripe-go/v82/webhook"
+)
+
+func TestStripeOfficialSignatureVerifier(t *testing.T) {
+ payload := []byte(`{"id":"evt_1","object":"event","api_version":"2025-06-30.basil","created":123,"type":"customer.subscription.updated","data":{"object":{"id":"sub_1","customer":"cus_1","status":"active","items":{"data":[{"price":{"id":"price_pro"},"current_period_start":10,"current_period_end":20}]}}}}`)
+ signed := webhook.GenerateTestSignedPayload(&webhook.UnsignedPayload{Payload: payload, Secret: "whsec_test", Timestamp: time.Now()})
+ e, err := NewStripeProvider("sk_test_unused").VerifyEvent(signed.Payload, signed.Header, "whsec_test")
+ require.NoError(t, err)
+ require.Equal(t, "price_pro", e.PriceID)
+ require.Equal(t, "cus_1", e.CustomerID)
+ _, err = NewStripeProvider("sk_test_unused").VerifyEvent(signed.Payload, signed.Header, "wrong")
+ require.ErrorIs(t, err, ErrInvalidSignature)
+}
diff --git a/apps/backend/internal/module/job/domain/job.go b/apps/backend/internal/module/job/domain/job.go
index 3d138c3..8198d2c 100644
--- a/apps/backend/internal/module/job/domain/job.go
+++ b/apps/backend/internal/module/job/domain/job.go
@@ -45,6 +45,9 @@ type Job struct {
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"`
+ LeaseOwner string `bson:"lease_owner,omitempty" json:"lease_owner,omitempty"`
+ LeaseExpiresAt int64 `bson:"lease_expires_at,omitempty" json:"lease_expires_at,omitempty"`
+ Attempt int `bson:"attempt,omitempty" json:"attempt,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"})
@@ -62,6 +65,13 @@ type Job struct {
// DefaultTokenRenewDelayNs — 連帳後約 30 天做第一次 renew(長效 token ~60 天)
const DefaultTokenRenewDelayNs = int64(30 * 24 * time.Hour)
+// Job leases are renewed by the claiming service while work is running.
+const (
+ DefaultLeaseDuration = time.Minute
+ DefaultLeaseDurationNs = int64(DefaultLeaseDuration)
+ DefaultHeartbeat = 20 * time.Second
+)
+
// TerminalRetentionNs — 終態任務(已完成/失敗/取消)保留多久後自動清除
const TerminalRetentionNs = int64(2 * 24 * time.Hour)
diff --git a/apps/backend/internal/module/job/domain/repository.go b/apps/backend/internal/module/job/domain/repository.go
index dec98fa..48768dc 100644
--- a/apps/backend/internal/module/job/domain/repository.go
+++ b/apps/backend/internal/module/job/domain/repository.go
@@ -5,11 +5,14 @@ import "context"
type Repository interface {
Insert(ctx context.Context, j *Job) error
Update(ctx context.Context, j *Job) error
+ // UpdateOwned atomically requires the current running lease to belong to leaseOwner.
+ UpdateOwned(ctx context.Context, j *Job, leaseOwner string) error
+ RenewLease(ctx context.Context, id, leaseOwner string, leaseExpiresAt int64) error
FindByID(ctx context.Context, id string) (*Job, error)
ListByOwner(ctx context.Context, ownerUID int64) ([]*Job, error)
// ListByOwnerTab 分頁+分桶;total 為該 tab 總筆數
ListByOwnerTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) (list []*Job, total int64, err error)
- // ClaimNext picks oldest due pending/queued (run_after <= now) and sets running + workerID.
+ // ClaimNext atomically claims a due queued job or a running job with an expired lease.
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
diff --git a/apps/backend/internal/module/job/repository/memory.go b/apps/backend/internal/module/job/repository/memory.go
index 600545f..d29c841 100644
--- a/apps/backend/internal/module/job/repository/memory.go
+++ b/apps/backend/internal/module/job/repository/memory.go
@@ -25,6 +25,14 @@ func (s *MemoryStore) Insert(_ context.Context, j *domain.Job) error {
}
func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error {
+ return s.update(j, "")
+}
+
+func (s *MemoryStore) UpdateOwned(_ context.Context, j *domain.Job, leaseOwner string) error {
+ return s.update(j, leaseOwner)
+}
+
+func (s *MemoryStore) update(j *domain.Job, leaseOwner string) error {
s.mu.Lock()
defer s.mu.Unlock()
stored, ok := s.byID[j.ID]
@@ -34,6 +42,9 @@ func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error {
if stored.Version != j.Version {
return domain.ErrIllegalStatus
}
+ if leaseOwner != "" && (stored.Status != domain.StatusRunning || stored.LeaseOwner != leaseOwner) {
+ return domain.ErrIllegalStatus
+ }
cp := *j
cp.Version++
s.byID[j.ID] = &cp
@@ -41,6 +52,21 @@ func (s *MemoryStore) Update(_ context.Context, j *domain.Job) error {
return nil
}
+func (s *MemoryStore) RenewLease(_ context.Context, id, leaseOwner string, leaseExpiresAt int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ j, ok := s.byID[id]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ if j.Status != domain.StatusRunning || j.LeaseOwner != leaseOwner {
+ return domain.ErrIllegalStatus
+ }
+ j.LeaseExpiresAt = leaseExpiresAt
+ j.UpdatedAt = domain.NowNano()
+ return nil
+}
+
func (s *MemoryStore) FindByID(_ context.Context, id string) (*domain.Job, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -115,10 +141,9 @@ func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job
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 {
+ queuedDue := (j.Status == domain.StatusPending || j.Status == domain.StatusQueued) && (j.RunAfter <= 0 || j.RunAfter <= now)
+ staleRunning := j.Status == domain.StatusRunning && j.LeaseExpiresAt <= now
+ if !queuedDue && !staleRunning {
continue
}
if best == nil || j.CreatedAt < best.CreatedAt {
@@ -129,13 +154,11 @@ func (s *MemoryStore) ClaimNext(_ context.Context, workerID string) (*domain.Job
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.Status = domain.StatusRunning
best.WorkerID = workerID
+ best.LeaseOwner = workerID
+ best.LeaseExpiresAt = now + domain.DefaultLeaseDurationNs
+ best.Attempt++
best.UpdatedAt = now
best.Version++
if best.ProgressSummary == "" {
diff --git a/apps/backend/internal/module/job/repository/mongo.go b/apps/backend/internal/module/job/repository/mongo.go
index 14b2514..93d2628 100644
--- a/apps/backend/internal/module/job/repository/mongo.go
+++ b/apps/backend/internal/module/job/repository/mongo.go
@@ -28,6 +28,15 @@ func (s *MonStore) Insert(ctx context.Context, j *domain.Job) error {
}
func (s *MonStore) Update(ctx context.Context, j *domain.Job) error {
+ return s.update(ctx, j, nil)
+}
+
+func (s *MonStore) UpdateOwned(ctx context.Context, j *domain.Job, leaseOwner string) error {
+ filter := bson.M{"status": domain.StatusRunning, "lease_owner": leaseOwner}
+ return s.update(ctx, j, filter)
+}
+
+func (s *MonStore) update(ctx context.Context, j *domain.Job, guard bson.M) error {
if j == nil {
return domain.ErrNotFound
}
@@ -35,7 +44,11 @@ func (s *MonStore) Update(ctx context.Context, j *domain.Job) error {
j.UpdatedAt = domain.NowNano()
candidate := *j
candidate.Version = expectedVersion + 1
- res, err := s.jobs.ReplaceOne(ctx, bson.M{"_id": j.ID, "version": expectedVersion}, &candidate)
+ filter := bson.M{"_id": j.ID, "version": expectedVersion}
+ for key, value := range guard {
+ filter[key] = value
+ }
+ res, err := s.jobs.ReplaceOne(ctx, filter, &candidate)
if err != nil {
return err
}
@@ -46,6 +59,21 @@ func (s *MonStore) Update(ctx context.Context, j *domain.Job) error {
return nil
}
+func (s *MonStore) RenewLease(ctx context.Context, id, leaseOwner string, leaseExpiresAt int64) error {
+ res, err := s.jobs.UpdateOne(ctx, bson.M{
+ "_id": id, "status": domain.StatusRunning, "lease_owner": leaseOwner,
+ }, bson.M{
+ "$set": bson.M{"lease_expires_at": leaseExpiresAt, "updated_at": domain.NowNano()},
+ })
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return domain.ErrIllegalStatus
+ }
+ 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})
@@ -120,21 +148,34 @@ func (s *MonStore) ListByOwnerTab(ctx context.Context, ownerUID int64, tab strin
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}},
+ {
+ "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}},
+ },
+ },
+ {
+ "status": domain.StatusRunning,
+ "$or": []bson.M{
+ {"lease_expires_at": bson.M{"$exists": false}},
+ {"lease_expires_at": 0},
+ {"lease_expires_at": bson.M{"$lte": now}},
+ },
+ },
},
}
update := bson.M{"$set": bson.M{
"status": domain.StatusRunning,
"worker_id": workerID,
+ "lease_owner": workerID,
+ "lease_expires_at": now + domain.DefaultLeaseDurationNs,
"updated_at": now,
"progress_summary": "已由 worker 領取(" + workerID + ")",
- }, "$inc": bson.M{"version": 1}}
+ }, "$inc": bson.M{"version": 1, "attempt": 1}}
opts := options.FindOneAndUpdate().
SetSort(bson.D{{Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}).
SetReturnDocument(options.After)
diff --git a/apps/backend/internal/module/job/usecase/service.go b/apps/backend/internal/module/job/usecase/service.go
index ce4b273..d35d415 100644
--- a/apps/backend/internal/module/job/usecase/service.go
+++ b/apps/backend/internal/module/job/usecase/service.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"strings"
+ "sync"
+ "time"
"apps/backend/internal/module/job/domain"
@@ -18,12 +20,84 @@ type Notifier interface {
}
type Service struct {
- Repo domain.Repository
- Notifier Notifier
+ Repo domain.Repository
+ Notifier Notifier
+ LeaseDuration time.Duration
+ HeartbeatInterval time.Duration
+
+ mu sync.Mutex
+ workerID string
+ heartbeats map[string]context.CancelFunc
}
func New(repo domain.Repository) *Service {
- return &Service{Repo: repo}
+ return &Service{
+ Repo: repo, LeaseDuration: domain.DefaultLeaseDuration,
+ HeartbeatInterval: domain.DefaultHeartbeat,
+ heartbeats: make(map[string]context.CancelFunc),
+ }
+}
+
+func (s *Service) leaseDuration() time.Duration {
+ if s.LeaseDuration > 0 {
+ return s.LeaseDuration
+ }
+ return domain.DefaultLeaseDuration
+}
+
+func (s *Service) leaseOwner() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.workerID
+}
+
+func (s *Service) update(ctx context.Context, j *domain.Job) error {
+ if owner := s.leaseOwner(); owner != "" {
+ j.LeaseExpiresAt = domain.NowNano() + int64(s.leaseDuration())
+ return s.Repo.UpdateOwned(ctx, j, owner)
+ }
+ return s.Repo.Update(ctx, j)
+}
+
+func (s *Service) startHeartbeat(ctx context.Context, jobID, workerID string) {
+ heartbeatCtx, cancel := context.WithCancel(ctx)
+ s.mu.Lock()
+ if previous := s.heartbeats[jobID]; previous != nil {
+ previous()
+ }
+ s.heartbeats[jobID] = cancel
+ s.mu.Unlock()
+
+ go func() {
+ interval := s.HeartbeatInterval
+ if interval <= 0 {
+ interval = domain.DefaultHeartbeat
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-heartbeatCtx.Done():
+ return
+ case <-ticker.C:
+ expiresAt := domain.NowNano() + int64(s.leaseDuration())
+ if err := s.Repo.RenewLease(heartbeatCtx, jobID, workerID, expiresAt); err != nil {
+ s.stopHeartbeat(jobID)
+ return
+ }
+ }
+ }
+ }()
+}
+
+func (s *Service) stopHeartbeat(jobID string) {
+ s.mu.Lock()
+ cancel := s.heartbeats[jobID]
+ delete(s.heartbeats, jobID)
+ s.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
}
// StartDemo — JB-01
@@ -112,9 +186,12 @@ func (s *Service) Transition(ctx context.Context, id, to, summary string, percen
}
}
}
- if err := s.Repo.Update(ctx, j); err != nil {
+ if err := s.update(ctx, j); err != nil {
return nil, err
}
+ if domain.IsTerminal(to) {
+ s.stopHeartbeat(id)
+ }
if s.Notifier != nil && (to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled) {
pct := j.ProgressPercent
if to == domain.StatusSucceeded {
@@ -126,10 +203,17 @@ func (s *Service) Transition(ctx context.Context, id, to, summary string, percen
}
func (s *Service) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) {
+ workerID = strings.TrimSpace(workerID)
+ if workerID == "" {
+ return nil, domain.ErrForbidden
+ }
j, err := s.Repo.ClaimNext(ctx, workerID)
if err != nil {
return nil, err
}
+ s.mu.Lock()
+ s.workerID = workerID
+ s.mu.Unlock()
// claim → running:立刻寫鈴鐺/列表進度(所有模板共用)
if j.ProgressPercent < 5 {
j.ProgressPercent = 5
@@ -138,7 +222,10 @@ func (s *Service) ClaimNext(ctx context.Context, workerID string) (*domain.Job,
j.ProgressSummary = "已由 worker 領取 · 執行中"
}
j.UpdatedAt = domain.NowNano()
- _ = s.Repo.Update(ctx, j)
+ if err := s.Repo.UpdateOwned(ctx, j, workerID); err != nil {
+ return nil, err
+ }
+ s.startHeartbeat(ctx, j.ID, workerID)
s.notify(ctx, j)
return j, nil
}
@@ -159,7 +246,7 @@ func (s *Service) RunDemoToSuccess(ctx context.Context, jobID string) (*domain.J
j.ProgressSummary = "Demo 測試任務 · 執行中"
j.ProgressPercent = 5
j.UpdatedAt = domain.NowNano()
- _ = s.Repo.Update(ctx, j)
+ _ = s.update(ctx, j)
s.notify(ctx, j)
}
if j.Status != domain.StatusRunning {
@@ -351,9 +438,10 @@ func (s *Service) SucceedJobWithPayload(ctx context.Context, jobID, summary, pay
j.Status = domain.StatusSucceeded
j.CompletedAt = domain.NowNano()
j.UpdatedAt = j.CompletedAt
- if err := s.Repo.Update(ctx, j); err != nil {
+ if err := s.update(ctx, j); err != nil {
return nil, err
}
+ s.stopHeartbeat(jobID)
s.notify(ctx, j)
return j, nil
}
@@ -406,7 +494,7 @@ func (s *Service) MarkRunningProgress(ctx context.Context, jobID string, pct int
if err := domain.ApplyProgress(j, pct, summary); err != nil {
return nil, err
}
- if err := s.Repo.Update(ctx, j); err != nil {
+ if err := s.update(ctx, j); err != nil {
return nil, err
}
// 每次有進度就更新鈴鐺(upsert 同一則)
@@ -454,7 +542,7 @@ func (s *Service) FailJob(ctx context.Context, jobID, errMsg string) (*domain.Jo
if j.Status == domain.StatusPending || j.Status == domain.StatusQueued {
j.Status = domain.StatusRunning
j.UpdatedAt = domain.NowNano()
- _ = s.Repo.Update(ctx, j)
+ _ = s.update(ctx, j)
}
if !domain.CanTransition(j.Status, domain.StatusFailed) {
return nil, domain.ErrIllegalStatus
@@ -471,9 +559,10 @@ func (s *Service) FailJob(ctx context.Context, jobID, errMsg string) (*domain.Jo
}
j.CompletedAt = domain.NowNano()
j.UpdatedAt = j.CompletedAt
- if err := s.Repo.Update(ctx, j); err != nil {
+ if err := s.update(ctx, j); err != nil {
return nil, err
}
+ s.stopHeartbeat(jobID)
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
index ce83d62..8e72cf1 100644
--- a/apps/backend/internal/module/job/usecase/service_test.go
+++ b/apps/backend/internal/module/job/usecase/service_test.go
@@ -3,6 +3,7 @@ package usecase_test
import (
"context"
"testing"
+ "time"
"apps/backend/internal/module/appnotif/repository"
appUC "apps/backend/internal/module/appnotif/usecase"
@@ -274,3 +275,84 @@ func TestJB_ListTab_BucketsAndPaging(t *testing.T) {
require.NoError(t, err)
require.Len(t, page1, 1)
}
+
+func TestJobLease_ReclaimsStaleRunningAndRejectsOldOwner(t *testing.T) {
+ ctx1, cancel1 := context.WithCancel(context.Background())
+ defer cancel1()
+ ctx2, cancel2 := context.WithCancel(context.Background())
+ defer cancel2()
+ repo := jobRepo.NewMemory()
+ worker1 := usecase.New(repo)
+ worker2 := usecase.New(repo)
+
+ queued, err := worker1.StartDemo(context.Background(), 1_000_300)
+ require.NoError(t, err)
+ first, err := worker1.ClaimNext(ctx1, "worker-1")
+ require.NoError(t, err)
+ require.Equal(t, queued.ID, first.ID)
+ require.Equal(t, "worker-1", first.LeaseOwner)
+ require.Equal(t, 1, first.Attempt)
+ require.Greater(t, first.LeaseExpiresAt, domain.NowNano())
+
+ _, err = worker2.ClaimNext(ctx2, "worker-2")
+ require.ErrorIs(t, err, domain.ErrNotFound)
+
+ stale, err := repo.FindByID(context.Background(), queued.ID)
+ require.NoError(t, err)
+ stale.LeaseExpiresAt = domain.NowNano() - 1
+ require.NoError(t, repo.Update(context.Background(), stale))
+
+ reclaimed, err := worker2.ClaimNext(ctx2, "worker-2")
+ require.NoError(t, err)
+ require.Equal(t, "worker-2", reclaimed.LeaseOwner)
+ require.Equal(t, 2, reclaimed.Attempt)
+
+ _, err = worker1.MarkRunningProgress(context.Background(), queued.ID, 30, "stale write")
+ require.ErrorIs(t, err, domain.ErrIllegalStatus)
+ _, err = worker1.SucceedJob(context.Background(), queued.ID, "stale completion")
+ require.ErrorIs(t, err, domain.ErrIllegalStatus)
+ _, err = worker1.FailJob(context.Background(), queued.ID, "stale failure")
+ require.ErrorIs(t, err, domain.ErrIllegalStatus)
+ updated, err := worker2.MarkRunningProgress(context.Background(), queued.ID, 30, "current write")
+ require.NoError(t, err)
+ require.Equal(t, "current write", updated.ProgressSummary)
+ completed, err := worker2.SucceedJob(context.Background(), queued.ID, "current completion")
+ require.NoError(t, err)
+ require.Equal(t, domain.StatusSucceeded, completed.Status)
+}
+
+func TestJobLease_HeartbeatRenewsWhileRunning(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ repo := jobRepo.NewMemory()
+ svc := usecase.New(repo)
+ svc.HeartbeatInterval = 5 * time.Millisecond
+ svc.LeaseDuration = 2 * domain.DefaultLeaseDuration
+
+ _, err := svc.StartDemo(context.Background(), 1_000_301)
+ require.NoError(t, err)
+ claimed, err := svc.ClaimNext(ctx, "worker-heartbeat")
+ require.NoError(t, err)
+ initialExpiry := claimed.LeaseExpiresAt
+
+ require.Eventually(t, func() bool {
+ current, findErr := repo.FindByID(context.Background(), claimed.ID)
+ return findErr == nil && current.LeaseExpiresAt > initialExpiry
+ }, 200*time.Millisecond, 5*time.Millisecond)
+}
+
+func TestJobLease_ReclaimsLegacyRunningDocumentWithoutLease(t *testing.T) {
+ repo := jobRepo.NewMemory()
+ now := domain.NowNano()
+ legacy := &domain.Job{
+ ID: "legacy-running", OwnerUID: 1_000_302, TemplateType: domain.TemplateDemo,
+ Status: domain.StatusRunning, WorkerID: "dead-worker", CreatedAt: now, UpdatedAt: now,
+ }
+ require.NoError(t, repo.Insert(context.Background(), legacy))
+
+ reclaimed, err := repo.ClaimNext(context.Background(), "replacement-worker")
+ require.NoError(t, err)
+ require.Equal(t, "replacement-worker", reclaimed.LeaseOwner)
+ require.Equal(t, 1, reclaimed.Attempt)
+ require.Greater(t, reclaimed.LeaseExpiresAt, now)
+}
diff --git a/apps/backend/internal/module/member/domain/errors.go b/apps/backend/internal/module/member/domain/errors.go
index b47f856..e8a3bcf 100644
--- a/apps/backend/internal/module/member/domain/errors.go
+++ b/apps/backend/internal/module/member/domain/errors.go
@@ -24,12 +24,13 @@ var (
// ErrWeakPassword — API 400003;Message = PasswordPolicyEN
ErrWeakPassword = errors.New(PasswordPolicyEN)
ErrInvalidCode = errors.New("invalid or expired code")
+ ErrCodeRateLimited = errors.New("verification code requested too frequently")
ErrAlreadyBound = errors.New("already bound")
ErrInvalidPlatform = errors.New("invalid platform")
ErrOAuthFailed = errors.New("oauth verification failed")
// 邀請網絡(message = FE i18n key invite.err.*)
- ErrInviteAlreadyBound = errors.New("invite.err.alreadyBound")
+ ErrInviteAlreadyBound = errors.New("invite.err.alreadyBound")
ErrInviteCodeRequired = errors.New("invite.err.codeRequired")
ErrInviteCodeNotFound = errors.New("invite.err.codeNotFound")
ErrInviteCodeSelf = errors.New("invite.err.codeSelf")
diff --git a/apps/backend/internal/module/member/domain/repository.go b/apps/backend/internal/module/member/domain/repository.go
index a8307aa..52402ea 100644
--- a/apps/backend/internal/module/member/domain/repository.go
+++ b/apps/backend/internal/module/member/domain/repository.go
@@ -29,7 +29,8 @@ type Repository interface {
UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error
// Codes / refresh (Mongo durable)
- SetCode(kind, key, code string, ttl time.Duration)
+ // SetCode returns false when a recent code exists or persistence fails.
+ SetCode(kind, key, code string, ttl time.Duration) bool
// 驗證碼共用語意(信箱驗證 / 重設密碼相同):
// CheckCode:碼正確才刪(成功消費);錯誤不刪;過期才刪
// PeekCode:只檢查不消費(業務前先 Peek,成功後再 Check)
diff --git a/apps/backend/internal/module/member/repository/mongo_store.go b/apps/backend/internal/module/member/repository/mongo_store.go
index ec566ea..209cc3e 100644
--- a/apps/backend/internal/module/member/repository/mongo_store.go
+++ b/apps/backend/internal/module/member/repository/mongo_store.go
@@ -2,20 +2,25 @@ package repository
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"strings"
"time"
libmongo "apps/backend/internal/lib/mongo"
+ "apps/backend/internal/lib/secretbox"
"apps/backend/internal/module/member/domain"
+ "github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/core/stores/mon"
"github.com/zeromicro/go-zero/core/stores/monc"
+ "github.com/zeromicro/go-zero/core/stores/redis"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
+ "golang.org/x/crypto/bcrypt"
)
const (
@@ -25,33 +30,45 @@ const (
colCounters = "counters"
colRefresh = "refresh_tokens"
colAuthCodes = "auth_codes"
+
+ settingsCacheVersion = 1
+ settingsCacheTTL = 30 * time.Minute
+ settingsNegativeCacheTTL = time.Minute
+ settingsCacheTimeout = 750 * time.Millisecond
+ settingsMongoReadTimeout = 5 * time.Second
)
-// MoncStore: members/identities/settings via monc; refresh/codes durable Mongo.
+// MoncStore: members/identities via monc; settings use explicit ciphertext Redis caching.
type MoncStore struct {
- uri string
- db string
- member *monc.Model
- plain *mon.Model
- identity *monc.Model
- idPlain *mon.Model
- settings *monc.Model
- refresh *mon.Model
- codes *mon.Model
+ uri string
+ db string
+ member *monc.Model
+ plain *mon.Model
+ identity *monc.Model
+ idPlain *mon.Model
+ settings *mon.Model
+ settingsCache settingsCache
+ settingsCacheNamespace string
+ refresh *mon.Model
+ codes *mon.Model
+ secrets *secretbox.Codec
}
-func NewMoncStore(uri, database string, cacheConf cache.CacheConf) *MoncStore {
+func NewMoncStore(uri, database string, cacheConf cache.CacheConf, settingsRedisConf redis.RedisConf, redisNamespace string, secrets *secretbox.Codec) *MoncStore {
uri = libmongo.MustMongoURI(uri)
return &MoncStore{
- uri: uri,
- db: database,
- member: monc.MustNewModel(uri, database, colMembers, cacheConf),
- plain: mon.MustNewModel(uri, database, colMembers),
- identity: monc.MustNewModel(uri, database, colIdentities, cacheConf),
- idPlain: mon.MustNewModel(uri, database, colIdentities),
- settings: monc.MustNewModel(uri, database, colSettings, cacheConf),
- refresh: mon.MustNewModel(uri, database, colRefresh),
- codes: mon.MustNewModel(uri, database, colAuthCodes),
+ uri: uri,
+ db: database,
+ member: monc.MustNewModel(uri, database, colMembers, cacheConf),
+ plain: mon.MustNewModel(uri, database, colMembers),
+ identity: monc.MustNewModel(uri, database, colIdentities, cacheConf),
+ idPlain: mon.MustNewModel(uri, database, colIdentities),
+ settings: mon.MustNewModel(uri, database, colSettings),
+ settingsCache: &redisSettingsCache{client: redis.MustNewRedis(settingsRedisConf)},
+ settingsCacheNamespace: normalizeSettingsCacheNamespace(redisNamespace),
+ refresh: mon.MustNewModel(uri, database, colRefresh),
+ codes: mon.MustNewModel(uri, database, colAuthCodes),
+ secrets: secrets,
}
}
@@ -62,7 +79,6 @@ func cacheKeyEmail(email string) string {
func cacheKeyPhone(phone string) string {
return fmt.Sprintf("cache:member:phone:%s", strings.TrimSpace(phone))
}
-func cacheKeySettings(uid int64) string { return fmt.Sprintf("cache:member:settings:%d", uid) }
func cacheKeyIdentity(loginID, platform string) string {
return fmt.Sprintf("cache:identity:%s:%s", platform, strings.ToLower(loginID))
}
@@ -347,19 +363,37 @@ type authCodeDoc struct {
ID string `bson:"_id"`
Kind string `bson:"kind"`
Key string `bson:"key"`
- Code string `bson:"code"`
+ Code string `bson:"code,omitempty"` // legacy plaintext reader
+ CodeHash string `bson:"code_hash,omitempty"`
+ Attempts int `bson:"attempts"`
+ CreatedAt time.Time `bson:"created_at"`
ExpiresAt time.Time `bson:"expires_at"`
}
-func (s *MoncStore) SetCode(kind, key, code string, ttl time.Duration) {
+func (s *MoncStore) SetCode(kind, key, code string, ttl time.Duration) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- id := kind + ":" + key
- doc := authCodeDoc{
- ID: id, Kind: kind, Key: key, Code: code,
- ExpiresAt: time.Now().UTC().Add(ttl),
+ hash, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.MinCost)
+ if err != nil {
+ return false
}
- _, _ = s.codes.ReplaceOne(ctx, bson.M{"_id": id}, doc, options.Replace().SetUpsert(true))
+ now := time.Now().UTC()
+ id := kind + ":" + key
+ filter := bson.M{"_id": id, "$or": bson.A{
+ bson.M{"created_at": bson.M{"$lte": now.Add(-time.Minute)}},
+ bson.M{"created_at": bson.M{"$exists": false}},
+ bson.M{"expires_at": bson.M{"$lte": now}},
+ }}
+ update := bson.M{"$set": bson.M{
+ "kind": kind, "key": key, "code_hash": string(hash), "attempts": 0,
+ "created_at": now, "expires_at": now.Add(ttl),
+ }, "$unset": bson.M{"code": ""}}
+ var doc authCodeDoc
+ err = s.codes.FindOneAndUpdate(ctx, &doc, filter, update, options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After))
+ if err != nil {
+ return false
+ }
+ return true
}
// CheckCode 比對驗證碼:
@@ -367,41 +401,40 @@ func (s *MoncStore) SetCode(kind, key, code string, ttl time.Duration) {
// - 錯誤 → 不刪,TTL 內可重試
// - 過期 → 刪除(清垃圾)並回 false
func (s *MoncStore) CheckCode(kind, key, code string) bool {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- id := kind + ":" + key
- var doc authCodeDoc
- err := s.codes.FindOne(ctx, &doc, bson.M{"_id": id})
- if err != nil {
- return false
- }
- if time.Now().UTC().After(doc.ExpiresAt) {
- _, _ = s.codes.DeleteOne(ctx, bson.M{"_id": id})
- return false
- }
- if doc.Code != code {
- // 輸錯不銷毀:N 分鐘內可繼續試,不必重寄
- return false
- }
- _, _ = s.codes.DeleteOne(ctx, bson.M{"_id": id})
- return true
+ return s.verifyCode(kind, key, code, true)
}
// PeekCode 只檢查、不消費;過期時順便刪除。
func (s *MoncStore) PeekCode(kind, key, code string) bool {
+ return s.verifyCode(kind, key, code, false)
+}
+
+func (s *MoncStore) verifyCode(kind, key, code string, consume bool) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
id := kind + ":" + key
var doc authCodeDoc
- err := s.codes.FindOne(ctx, &doc, bson.M{"_id": id})
+ now := time.Now().UTC()
+ err := s.codes.FindOneAndUpdate(ctx, &doc, bson.M{
+ "_id": id, "expires_at": bson.M{"$gt": now}, "attempts": bson.M{"$lt": 5},
+ }, bson.M{"$inc": bson.M{"attempts": 1}}, options.FindOneAndUpdate().SetReturnDocument(options.Before))
if err != nil {
return false
}
- if time.Now().UTC().After(doc.ExpiresAt) {
- _, _ = s.codes.DeleteOne(ctx, bson.M{"_id": id})
+ matched := false
+ if doc.CodeHash != "" {
+ matched = bcrypt.CompareHashAndPassword([]byte(doc.CodeHash), []byte(code)) == nil
+ } else {
+ matched = doc.Code == code
+ }
+ if !matched {
return false
}
- return doc.Code == code
+ if consume {
+ _, err = s.codes.DeleteOne(ctx, bson.M{"_id": id})
+ return err == nil
+ }
+ return true
}
// --- durable refresh_tokens ---
@@ -446,26 +479,312 @@ func (s *MoncStore) RevokeAllRefreshByUID(ctx context.Context, uid int64) error
// --- settings ---
+type settingsCache interface {
+ Get(context.Context, string) (string, error)
+ Set(context.Context, string, string, time.Duration) error
+ Delete(context.Context, string) error
+}
+
+type redisSettingsCache struct {
+ client *redis.Redis
+}
+
+func (c *redisSettingsCache) Get(ctx context.Context, key string) (string, error) {
+ return c.client.GetCtx(ctx, key)
+}
+
+func (c *redisSettingsCache) Set(ctx context.Context, key, value string, ttl time.Duration) error {
+ return c.client.SetexCtx(ctx, key, value, durationSeconds(ttl))
+}
+
+func (c *redisSettingsCache) Delete(ctx context.Context, key string) error {
+ _, err := c.client.DelCtx(ctx, key)
+ return err
+}
+
+type settingsCacheDTO struct {
+ Version int `json:"version"`
+ Found bool `json:"found"`
+ UID int64 `json:"uid"`
+ Provider string `json:"provider,omitempty"`
+ Model string `json:"model,omitempty"`
+ APIKeyConfigured bool `json:"api_key_configured,omitempty"`
+ ResearchAPIKeyConfigured bool `json:"research_api_key_configured,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ ProviderAPIKeys map[string]string `json:"provider_api_keys,omitempty"`
+ ExaAPIKey string `json:"exa_api_key,omitempty"`
+ WebSearchProvider string `json:"web_search_provider,omitempty"`
+ ExpandStrategy string `json:"expand_strategy,omitempty"`
+ ExaAPIKeyConfigured bool `json:"exa_api_key_configured,omitempty"`
+ DevModeEnabled bool `json:"dev_mode_enabled,omitempty"`
+ UpdatedAt int64 `json:"updated_at,omitempty"`
+}
+
+func settingsCacheKey(namespace string, uid int64) string {
+ return fmt.Sprintf("%s:member:settings:{%d}", normalizeSettingsCacheNamespace(namespace), uid)
+}
+
+func normalizeSettingsCacheNamespace(namespace string) string {
+ if namespace = strings.Trim(strings.TrimSpace(namespace), ":"); namespace != "" {
+ return namespace
+ }
+ return "haixun:dev:v1"
+}
+
+func newSettingsCacheDTO(st *domain.UserSettings) (settingsCacheDTO, error) {
+ if st == nil {
+ return settingsCacheDTO{}, errors.New("nil member settings cache value")
+ }
+ if err := validateCachedCiphertext(st); err != nil {
+ return settingsCacheDTO{}, err
+ }
+ return settingsCacheDTO{
+ Version: settingsCacheVersion, Found: true, UID: st.UID,
+ Provider: st.Provider, Model: st.Model,
+ APIKeyConfigured: st.APIKeyConfigured, ResearchAPIKeyConfigured: st.ResearchAPIKeyConfigured,
+ APIKey: st.APIKey, ProviderAPIKeys: cloneStringMap(st.ProviderAPIKeys), ExaAPIKey: st.ExaAPIKey,
+ WebSearchProvider: st.WebSearchProvider, ExpandStrategy: st.ExpandStrategy,
+ ExaAPIKeyConfigured: st.ExaAPIKeyConfigured, DevModeEnabled: st.DevModeEnabled, UpdatedAt: st.UpdatedAt,
+ }, nil
+}
+
+func (d settingsCacheDTO) settings() *domain.UserSettings {
+ return &domain.UserSettings{
+ UID: d.UID, Provider: d.Provider, Model: d.Model,
+ APIKeyConfigured: d.APIKeyConfigured, ResearchAPIKeyConfigured: d.ResearchAPIKeyConfigured,
+ APIKey: d.APIKey, ProviderAPIKeys: cloneStringMap(d.ProviderAPIKeys), ExaAPIKey: d.ExaAPIKey,
+ WebSearchProvider: d.WebSearchProvider, ExpandStrategy: d.ExpandStrategy,
+ ExaAPIKeyConfigured: d.ExaAPIKeyConfigured, DevModeEnabled: d.DevModeEnabled, UpdatedAt: d.UpdatedAt,
+ }
+}
+
+func validateCachedCiphertext(st *domain.UserSettings) error {
+ values := map[string]string{"api_key": st.APIKey, "exa_api_key": st.ExaAPIKey}
+ for provider, value := range st.ProviderAPIKeys {
+ values["provider_api_keys."+provider] = value
+ }
+ for field, value := range values {
+ if value != "" && !secretbox.IsEncrypted(value) {
+ return fmt.Errorf("member settings cache %s is not ciphertext", field)
+ }
+ }
+ return nil
+}
+
+func encodeSettingsCache(dto settingsCacheDTO) (string, error) {
+ raw, err := json.Marshal(dto)
+ return string(raw), err
+}
+
+func decodeSettingsCache(raw string, uid int64) (settingsCacheDTO, error) {
+ var dto settingsCacheDTO
+ if err := json.Unmarshal([]byte(raw), &dto); err != nil {
+ return settingsCacheDTO{}, err
+ }
+ if dto.Version != settingsCacheVersion || dto.UID != uid {
+ return settingsCacheDTO{}, errors.New("invalid member settings cache version or uid")
+ }
+ if dto.Found {
+ if err := validateCachedCiphertext(dto.settings()); err != nil {
+ return settingsCacheDTO{}, err
+ }
+ }
+ return dto, nil
+}
+
+func readSettingsCache(ctx context.Context, cache settingsCache, key string, uid int64, codec *secretbox.Codec) (*domain.UserSettings, bool, error) {
+ raw, err := cache.Get(ctx, key)
+ if err != nil || raw == "" {
+ return nil, false, err
+ }
+ dto, err := decodeSettingsCache(raw, uid)
+ if err != nil {
+ _ = cache.Delete(ctx, key)
+ return nil, false, err
+ }
+ if !dto.Found {
+ return domain.DefaultSettings(uid), true, nil
+ }
+ st, err := decryptSettings(codec, uid, dto.settings())
+ if err != nil {
+ _ = cache.Delete(ctx, key)
+ return nil, false, err
+ }
+ return st, true, nil
+}
+
func (s *MoncStore) GetSettings(ctx context.Context, uid int64) (*domain.UserSettings, error) {
- var st domain.UserSettings
- err := s.settings.FindOne(ctx, cacheKeySettings(uid), &st, bson.M{"uid": uid})
- if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) {
+ key := settingsCacheKey(s.settingsCacheNamespace, uid)
+ cacheCtx, cacheCancel := context.WithTimeout(ctx, settingsCacheTimeout)
+ st, hit, cacheErr := readSettingsCache(cacheCtx, s.settingsCache, key, uid, s.secrets)
+ cacheCancel()
+ if hit {
+ return st, nil
+ }
+ if cacheErr != nil {
+ logx.Errorf("member settings cache read failed uid=%d: %v", uid, cacheErr)
+ }
+
+ mongoCtx, mongoCancel := context.WithTimeout(ctx, settingsMongoReadTimeout)
+ defer mongoCancel()
+ var encrypted domain.UserSettings
+ err := s.settings.FindOne(mongoCtx, &encrypted, bson.M{"uid": uid})
+ if errors.Is(err, mon.ErrNotFound) || errors.Is(err, mongo.ErrNoDocuments) {
+ s.writeSettingsCache(key, settingsCacheDTO{Version: settingsCacheVersion, UID: uid}, settingsNegativeCacheTTL)
return domain.DefaultSettings(uid), nil
}
if err != nil {
return nil, err
}
- return &st, nil
+ st, err = decryptSettings(s.secrets, uid, &encrypted)
+ if err != nil {
+ return nil, err
+ }
+ cacheValue := &encrypted
+ if err := validateCachedCiphertext(cacheValue); err != nil {
+ cacheValue, err = encryptSettings(s.secrets, uid, st)
+ if err != nil {
+ logx.Errorf("member settings cache legacy refill skipped uid=%d: %v", uid, err)
+ return st, nil
+ }
+ }
+ dto, err := newSettingsCacheDTO(cacheValue)
+ if err == nil {
+ s.writeSettingsCache(key, dto, settingsCacheTTL)
+ }
+ return st, nil
}
func (s *MoncStore) SaveSettings(ctx context.Context, uid int64, st *domain.UserSettings) error {
- st.UID = uid
- st.UpdatedAt = time.Now().UTC().UnixNano()
- settingsPlain := mon.MustNewModel(s.uri, s.db, colSettings)
- _, err := settingsPlain.ReplaceOne(ctx, bson.M{"uid": uid}, st, options.Replace().SetUpsert(true))
+ encrypted, err := encryptSettings(s.secrets, uid, st)
if err != nil {
return err
}
- _ = s.settings.DelCache(ctx, cacheKeySettings(uid))
+ encrypted.UID = uid
+ encrypted.UpdatedAt = time.Now().UTC().UnixNano()
+ _, err = s.settings.ReplaceOne(ctx, bson.M{"uid": uid}, encrypted, options.Replace().SetUpsert(true))
+ if err != nil {
+ return err
+ }
+ dto, cacheErr := newSettingsCacheDTO(encrypted)
+ if cacheErr == nil {
+ cacheErr = s.writeSettingsCache(settingsCacheKey(s.settingsCacheNamespace, uid), dto, settingsCacheTTL)
+ }
+ if cacheErr != nil {
+ key := settingsCacheKey(s.settingsCacheNamespace, uid)
+ cacheCtx, cancel := context.WithTimeout(context.Background(), settingsCacheTimeout)
+ deleteErr := s.settingsCache.Delete(cacheCtx, key)
+ cancel()
+ logx.Errorf("CRITICAL: member settings saved to Mongo but Redis ciphertext cache write failed uid=%d cache_err=%v delete_err=%v", uid, cacheErr, deleteErr)
+ }
return nil
}
+
+func (s *MoncStore) writeSettingsCache(key string, dto settingsCacheDTO, ttl time.Duration) error {
+ raw, err := encodeSettingsCache(dto)
+ if err != nil {
+ return err
+ }
+ cacheCtx, cancel := context.WithTimeout(context.Background(), settingsCacheTimeout)
+ defer cancel()
+ if err := s.settingsCache.Set(cacheCtx, key, raw, ttl); err != nil {
+ logx.Errorf("member settings cache refill failed key=%s: %v", key, err)
+ return err
+ }
+ return nil
+}
+
+func durationSeconds(ttl time.Duration) int {
+ seconds := int(ttl.Seconds())
+ if seconds < 1 {
+ return 1
+ }
+ return seconds
+}
+
+func encryptSettings(codec *secretbox.Codec, uid int64, st *domain.UserSettings) (*domain.UserSettings, error) {
+ if st == nil {
+ return nil, domain.ErrNotFound
+ }
+ out := cloneSettings(st)
+ var err error
+ out.APIKey, err = encryptSetting(codec, uid, "api_key", out.Provider, out.APIKey)
+ if err != nil {
+ return nil, err
+ }
+ for provider, key := range out.ProviderAPIKeys {
+ out.ProviderAPIKeys[provider], err = encryptSetting(codec, uid, "provider_api_keys", provider, key)
+ if err != nil {
+ return nil, err
+ }
+ }
+ out.ExaAPIKey, err = encryptSetting(codec, uid, "exa_api_key", "exa", out.ExaAPIKey)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func decryptSettings(codec *secretbox.Codec, uid int64, st *domain.UserSettings) (*domain.UserSettings, error) {
+ if st == nil {
+ return nil, domain.ErrNotFound
+ }
+ out := cloneSettings(st)
+ var err error
+ out.APIKey, err = decryptSetting(codec, uid, "api_key", out.Provider, out.APIKey)
+ if err != nil {
+ return nil, err
+ }
+ for provider, key := range out.ProviderAPIKeys {
+ out.ProviderAPIKeys[provider], err = decryptSetting(codec, uid, "provider_api_keys", provider, key)
+ if err != nil {
+ return nil, err
+ }
+ }
+ out.ExaAPIKey, err = decryptSetting(codec, uid, "exa_api_key", "exa", out.ExaAPIKey)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func encryptSetting(codec *secretbox.Codec, uid int64, field, provider, value string) (string, error) {
+ if value == "" {
+ return "", nil
+ }
+ if codec == nil {
+ return "", fmt.Errorf("member settings encryption codec is required for %s", field)
+ }
+ return codec.Encrypt(uid, field, provider, value)
+}
+
+func decryptSetting(codec *secretbox.Codec, uid int64, field, provider, value string) (string, error) {
+ if value == "" || !secretbox.IsEncrypted(value) {
+ return value, nil
+ }
+ if codec == nil {
+ return "", fmt.Errorf("member settings encryption codec is required for %s", field)
+ }
+ plaintext, err := codec.Decrypt(uid, field, provider, value)
+ if err != nil {
+ return "", fmt.Errorf("decrypt member settings %s: %w", field, err)
+ }
+ return plaintext, nil
+}
+
+func cloneSettings(st *domain.UserSettings) *domain.UserSettings {
+ out := *st
+ out.ProviderAPIKeys = cloneStringMap(st.ProviderAPIKeys)
+ return &out
+}
+
+func cloneStringMap(values map[string]string) map[string]string {
+ if values == nil {
+ return nil
+ }
+ out := make(map[string]string, len(values))
+ for key, value := range values {
+ out[key] = value
+ }
+ return out
+}
diff --git a/apps/backend/internal/module/member/repository/settings_cache_test.go b/apps/backend/internal/module/member/repository/settings_cache_test.go
new file mode 100644
index 0000000..1dd4fcc
--- /dev/null
+++ b/apps/backend/internal/module/member/repository/settings_cache_test.go
@@ -0,0 +1,102 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "apps/backend/internal/module/member/domain"
+)
+
+type fakeSettingsCache struct {
+ value string
+ getErr error
+ deleted bool
+}
+
+func (c *fakeSettingsCache) Get(context.Context, string) (string, error) {
+ return c.value, c.getErr
+}
+
+func (c *fakeSettingsCache) Set(context.Context, string, string, time.Duration) error {
+ return nil
+}
+
+func (c *fakeSettingsCache) Delete(context.Context, string) error {
+ c.deleted = true
+ return nil
+}
+
+func TestSettingsCacheDTORoundTripRetainsCiphertextWithoutPlaintext(t *testing.T) {
+ codec := repositoryTestCodec(t)
+ plaintext := &domain.UserSettings{
+ UID: 42, Provider: "xai", Model: "grok-3", APIKey: "legacy-plain-secret",
+ ProviderAPIKeys: map[string]string{"xai": "provider-plain-secret"}, ExaAPIKey: "exa-plain-secret",
+ WebSearchProvider: "exa", ExpandStrategy: "hybrid", UpdatedAt: 123,
+ }
+ encrypted, err := encryptSettings(codec, plaintext.UID, plaintext)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dto, err := newSettingsCacheDTO(encrypted)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := encodeSettingsCache(dto)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, secret := range []string{plaintext.APIKey, plaintext.ProviderAPIKeys["xai"], plaintext.ExaAPIKey} {
+ if strings.Contains(raw, secret) {
+ t.Fatalf("serialized cache contains plaintext secret %q: %s", secret, raw)
+ }
+ }
+ decoded, err := decodeSettingsCache(raw, plaintext.UID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := decoded.settings()
+ if got.APIKey != encrypted.APIKey || got.ProviderAPIKeys["xai"] != encrypted.ProviderAPIKeys["xai"] || got.ExaAPIKey != encrypted.ExaAPIKey {
+ t.Fatalf("ciphertext did not survive DTO round-trip: %#v", got)
+ }
+}
+
+func TestSettingsCacheRejectsPlaintextSecrets(t *testing.T) {
+ _, err := newSettingsCacheDTO(&domain.UserSettings{UID: 42, APIKey: "plaintext"})
+ if err == nil {
+ t.Fatal("plaintext cache DTO was accepted")
+ }
+}
+
+func TestReadSettingsCacheCorruptionDeletesAndFallsBack(t *testing.T) {
+ cache := &fakeSettingsCache{value: `{"version":1,"found":true,"uid":42,"api_key":"plaintext"}`}
+ got, hit, err := readSettingsCache(context.Background(), cache, "settings-key", 42, repositoryTestCodec(t))
+ if err == nil || hit || got != nil {
+ t.Fatalf("corrupt lookup = %#v, hit=%v, err=%v", got, hit, err)
+ }
+ if !cache.deleted {
+ t.Fatal("corrupt cache entry was not deleted")
+ }
+}
+
+func TestReadSettingsCacheOutageFallsBackWithoutDeleting(t *testing.T) {
+ cache := &fakeSettingsCache{getErr: errors.New("redis unavailable")}
+ got, hit, err := readSettingsCache(context.Background(), cache, "settings-key", 42, repositoryTestCodec(t))
+ if err == nil || hit || got != nil {
+ t.Fatalf("outage lookup = %#v, hit=%v, err=%v", got, hit, err)
+ }
+ if cache.deleted {
+ t.Fatal("Redis outage should not trigger cache deletion")
+ }
+}
+
+func TestSettingsCacheKeyUsesConfiguredNamespace(t *testing.T) {
+ if got, want := settingsCacheKey("tenant:prod:v2", 42), "tenant:prod:v2:member:settings:{42}"; got != want {
+ t.Fatalf("settings cache key=%q, want %q", got, want)
+ }
+ if got, want := settingsCacheKey("", 42), "haixun:dev:v1:member:settings:{42}"; got != want {
+ t.Fatalf("default settings cache key=%q, want %q", got, want)
+ }
+}
diff --git a/apps/backend/internal/module/member/repository/settings_crypto_test.go b/apps/backend/internal/module/member/repository/settings_crypto_test.go
new file mode 100644
index 0000000..2e8ea45
--- /dev/null
+++ b/apps/backend/internal/module/member/repository/settings_crypto_test.go
@@ -0,0 +1,78 @@
+package repository
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "apps/backend/internal/lib/secretbox"
+ "apps/backend/internal/module/member/domain"
+)
+
+func repositoryTestCodec(t *testing.T) *secretbox.Codec {
+ t.Helper()
+ c, err := secretbox.New("repo-test", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c
+}
+
+func TestSettingsTransformEncryptsAllSecretsWithoutMutatingCaller(t *testing.T) {
+ codec := repositoryTestCodec(t)
+ original := &domain.UserSettings{
+ UID: 7, Provider: "xai", APIKey: "legacy-key", ExaAPIKey: "exa-key",
+ ProviderAPIKeys: map[string]string{"xai": "xai-key", "opencode-go": "oc-key", "empty": ""},
+ }
+ encrypted, err := encryptSettings(codec, 7, original)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if original.APIKey != "legacy-key" || original.ExaAPIKey != "exa-key" || original.ProviderAPIKeys["xai"] != "xai-key" {
+ t.Fatal("caller settings were mutated")
+ }
+ for name, value := range map[string]string{
+ "api": encrypted.APIKey, "exa": encrypted.ExaAPIKey,
+ "xai": encrypted.ProviderAPIKeys["xai"], "opencode": encrypted.ProviderAPIKeys["opencode-go"],
+ } {
+ if !strings.HasPrefix(value, "enc:v1:repo-test:") {
+ t.Fatalf("%s was not encrypted: %q", name, value)
+ }
+ }
+ if encrypted.ProviderAPIKeys["empty"] != "" {
+ t.Fatal("empty key should remain empty")
+ }
+ decrypted, err := decryptSettings(codec, 7, encrypted)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decrypted.APIKey != "legacy-key" || decrypted.ExaAPIKey != "exa-key" || decrypted.ProviderAPIKeys["opencode-go"] != "oc-key" {
+ t.Fatalf("unexpected plaintext: %#v", decrypted)
+ }
+}
+
+func TestSettingsTransformLegacyAndMissingCodecBehavior(t *testing.T) {
+ legacy := &domain.UserSettings{UID: 8, Provider: "xai", APIKey: "plain", ProviderAPIKeys: map[string]string{"xai": "plain-map"}}
+ got, err := decryptSettings(nil, 8, legacy)
+ if err != nil || got.APIKey != "plain" || got.ProviderAPIKeys["xai"] != "plain-map" {
+ t.Fatalf("legacy read = %#v, %v", got, err)
+ }
+ if _, err := encryptSettings(nil, 8, legacy); err == nil {
+ t.Fatal("nonempty secret save without codec must fail")
+ }
+ if _, err := encryptSettings(nil, 8, &domain.UserSettings{UID: 8, ProviderAPIKeys: map[string]string{}}); err != nil {
+ t.Fatalf("empty settings save should remain constructible: %v", err)
+ }
+
+ codec := repositoryTestCodec(t)
+ encrypted, err := encryptSettings(codec, 8, legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := decryptSettings(nil, 8, encrypted); err == nil {
+ t.Fatal("encrypted read without codec must fail")
+ }
+ if _, err := decryptSettings(codec, 9, encrypted); err == nil {
+ t.Fatal("ciphertext must not move between users")
+ }
+}
diff --git a/apps/backend/internal/module/member/usecase/account.go b/apps/backend/internal/module/member/usecase/account.go
index 64182d4..a535571 100644
--- a/apps/backend/internal/module/member/usecase/account.go
+++ b/apps/backend/internal/module/member/usecase/account.go
@@ -21,10 +21,10 @@ type AccountService struct {
Issuer tokenDomain.Issuer
BcryptCost int
// OAuth optional config (empty = dev mock path)
- GoogleClientID string
- LineClientID string
- LineClientSecret string
- LineRedirectURI string
+ GoogleClientID string
+ LineClientID string
+ LineClientSecret string
+ LineRedirectURI string
}
// AuthService kept as type alias for svc/logic compatibility.
@@ -295,16 +295,18 @@ func (s *AccountService) UpdateUserToken(ctx context.Context, loginID, platform,
// sync primary password on member if platform password identity
ident, err := s.Repo.FindIdentity(ctx, loginID, platform)
if err != nil {
- return nil
+ return err
}
m, err := s.Repo.FindByUID(ctx, ident.UID)
if err != nil {
- return nil
+ return err
}
m.PasswordHash = hash
m.UpdatedAt = domain.NowNano()
- _ = s.Repo.UpdateMember(ctx, m)
- return nil
+ if err := s.Repo.UpdateMember(ctx, m); err != nil {
+ return err
+ }
+ return s.Repo.RevokeAllRefreshByUID(ctx, ident.UID)
}
func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *domain.UpdateUserInfoPatch) (*domain.Member, error) {
@@ -370,7 +372,8 @@ func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *d
m.PhoneVerified = false
m.PhoneVerifiedAt = nil
}
- if patch.NewPassword != nil && *patch.NewPassword != "" {
+ passwordChanged := patch.NewPassword != nil && *patch.NewPassword != ""
+ if passwordChanged {
if patch.CurrentPassword == nil || !CheckPasswordHash(*patch.CurrentPassword, m.PasswordHash) {
return nil, domain.ErrBadPassword
}
@@ -388,6 +391,11 @@ func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *d
if err := s.Repo.UpdateMember(ctx, m); err != nil {
return nil, err
}
+ if passwordChanged {
+ if err := s.Repo.RevokeAllRefreshByUID(ctx, uid); err != nil {
+ return nil, err
+ }
+ }
return m, nil
}
@@ -546,7 +554,9 @@ func (s *AccountService) BindVerifyPhone(ctx context.Context, uid int64, phone s
func (s *AccountService) GenerateCode(ctx context.Context, loginID, codeType string) (string, error) {
code := randomDigits(6)
- s.Repo.SetCode(codeType, strings.ToLower(loginID), code, 10*time.Minute)
+ if !s.Repo.SetCode(codeType, strings.ToLower(loginID), code, 10*time.Minute) {
+ return "", domain.ErrCodeRateLimited
+ }
return code, nil
}
@@ -578,11 +588,14 @@ func (s *AccountService) VerifyPlatformAuthResult(ctx context.Context, loginID,
// ---------- OAuth providers ----------
func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) {
- info, e := googleUserInfo(ctx, idToken)
+ if strings.TrimSpace(s.GoogleClientID) == "" {
+ return "", "", "", domain.ErrOAuthFailed
+ }
+ info, e := googleIDTokenInfo(ctx, idToken, s.GoogleClientID)
if e != nil {
return "", "", "", domain.ErrOAuthFailed
}
- return info.ID, info.Email, info.Name, nil
+ return info.Subject, info.Email, info.Name, nil
}
func (s *AccountService) LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) {
@@ -636,11 +649,15 @@ func (s *AccountService) RequestPasswordReset(ctx context.Context, email string)
code := randomDigits(6)
// try identity or member — unknown email must error (product: 無此信箱不寄)
if _, err := s.Repo.FindByEmail(ctx, email); err == nil {
- s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute)
+ if !s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) {
+ return "", domain.ErrCodeRateLimited
+ }
return code, nil
}
if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err == nil {
- s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute)
+ if !s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) {
+ return "", domain.ErrCodeRateLimited
+ }
return code, nil
}
return "", domain.ErrEmailNotRegistered
@@ -661,6 +678,9 @@ func (s *AccountService) ResetPassword(ctx context.Context, email, code, newPass
}
// 3) 業務先寫入;成功才消費碼
if err := s.UpdateUserToken(ctx, email, domain.PlatformPassword, newPassword); err != nil {
+ if !errors.Is(err, domain.ErrIdentityNotFound) {
+ return err
+ }
// seed / legacy member without identity row
hash, herr := HashPassword(newPassword, s.BcryptCost)
if herr != nil {
@@ -680,6 +700,9 @@ func (s *AccountService) ResetPassword(ctx context.Context, email, code, newPass
AccountType: domain.AccountTypeEmail, PasswordHash: hash,
CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(),
})
+ if err := s.Repo.RevokeAllRefreshByUID(ctx, m.UID); err != nil {
+ return err
+ }
}
// 4) 成功才刪碼(兩種 kind 都清)
_ = s.Repo.CheckCode(domain.CodeTypeForgetPassword, email, code)
@@ -697,9 +720,13 @@ func (s *AccountService) SendEmailCode(ctx context.Context, uid int64) (string,
if key == "" {
key = fmt.Sprintf("%d", uid)
}
- s.Repo.SetCode(domain.CodeTypeEmail, strings.ToLower(key), code, 30*time.Minute)
+ if !s.Repo.SetCode(domain.CodeTypeEmail, strings.ToLower(key), code, 30*time.Minute) {
+ return "", domain.ErrCodeRateLimited
+ }
// also store by uid for VerifyEmailCode
- s.Repo.SetCode(domain.CodeTypeEmail, fmt.Sprintf("%d", uid), code, 30*time.Minute)
+ if key != fmt.Sprintf("%d", uid) {
+ _ = s.Repo.SetCode(domain.CodeTypeEmail, fmt.Sprintf("%d", uid), code, 30*time.Minute)
+ }
return code, nil
}
diff --git a/apps/backend/internal/module/member/usecase/auth_test.go b/apps/backend/internal/module/member/usecase/auth_test.go
index d66ffe7..44b42ca 100644
--- a/apps/backend/internal/module/member/usecase/auth_test.go
+++ b/apps/backend/internal/module/member/usecase/auth_test.go
@@ -244,10 +244,11 @@ func (f *fakeRepo) UpdateIdentityPassword(ctx context.Context, loginID, platform
return nil
}
-func (f *fakeRepo) SetCode(kind, key, code string, ttl time.Duration) {
+func (f *fakeRepo) SetCode(kind, key, code string, ttl time.Duration) bool {
f.mu.Lock()
defer f.mu.Unlock()
f.codes[kind+":"+key] = code
+ return true
}
func (f *fakeRepo) CheckCode(kind, key, code string) bool {
f.mu.Lock()
diff --git a/apps/backend/internal/module/member/usecase/oauth_providers.go b/apps/backend/internal/module/member/usecase/oauth_providers.go
index 4eb9880..9cca5dd 100644
--- a/apps/backend/internal/module/member/usecase/oauth_providers.go
+++ b/apps/backend/internal/module/member/usecase/oauth_providers.go
@@ -7,23 +7,26 @@ import (
"io"
"net/http"
"net/url"
+ "strconv"
"strings"
"time"
)
-type googleUserInfoResp struct {
- ID string `json:"id"`
- Email string `json:"email"`
- Name string `json:"name"`
+type googleIDTokenInfoResp struct {
+ Audience string `json:"aud"`
+ Issuer string `json:"iss"`
+ Subject string `json:"sub"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ Expires string `json:"exp"`
}
-func googleUserInfo(ctx context.Context, accessOrIDToken string) (*googleUserInfoResp, error) {
- // Prefer userinfo with Bearer access token (stand-alone style)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
+func googleIDTokenInfo(ctx context.Context, idToken, clientID string) (*googleIDTokenInfoResp, error) {
+ endpoint := "https://oauth2.googleapis.com/tokeninfo?id_token=" + url.QueryEscape(strings.TrimSpace(idToken))
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
- req.Header.Set("Authorization", "Bearer "+accessOrIDToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
@@ -32,18 +35,32 @@ func googleUserInfo(ctx context.Context, accessOrIDToken string) (*googleUserInf
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("google userinfo status %d: %s", resp.StatusCode, string(body))
+ return nil, fmt.Errorf("google tokeninfo status %d", resp.StatusCode)
}
- var info googleUserInfoResp
+ var info googleIDTokenInfoResp
if err := json.Unmarshal(body, &info); err != nil {
return nil, err
}
- if info.ID == "" {
- return nil, fmt.Errorf("google userinfo missing id")
+ if err := validateGoogleIDTokenInfo(&info, clientID, time.Now()); err != nil {
+ return nil, err
}
return &info, nil
}
+func validateGoogleIDTokenInfo(info *googleIDTokenInfoResp, clientID string, now time.Time) error {
+ if info == nil || info.Subject == "" || info.Audience != strings.TrimSpace(clientID) {
+ return fmt.Errorf("google ID token audience or subject is invalid")
+ }
+ if info.Issuer != "accounts.google.com" && info.Issuer != "https://accounts.google.com" {
+ return fmt.Errorf("google ID token issuer is invalid")
+ }
+ expires, err := strconv.ParseInt(info.Expires, 10, 64)
+ if err != nil || expires <= now.Unix() {
+ return fmt.Errorf("google ID token is expired")
+ }
+ return nil
+}
+
type lineTokenResp struct {
AccessToken string `json:"access_token"`
}
diff --git a/apps/backend/internal/module/member/usecase/oauth_providers_test.go b/apps/backend/internal/module/member/usecase/oauth_providers_test.go
new file mode 100644
index 0000000..2e80872
--- /dev/null
+++ b/apps/backend/internal/module/member/usecase/oauth_providers_test.go
@@ -0,0 +1,31 @@
+package usecase
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateGoogleIDTokenInfo(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0)
+ valid := &googleIDTokenInfoResp{
+ Audience: "client-123",
+ Issuer: "https://accounts.google.com",
+ Subject: "google-user",
+ Expires: "1700000060",
+ }
+ require.NoError(t, validateGoogleIDTokenInfo(valid, "client-123", now))
+
+ wrongAudience := *valid
+ wrongAudience.Audience = "other-client"
+ require.Error(t, validateGoogleIDTokenInfo(&wrongAudience, "client-123", now))
+
+ wrongIssuer := *valid
+ wrongIssuer.Issuer = "https://attacker.example"
+ require.Error(t, validateGoogleIDTokenInfo(&wrongIssuer, "client-123", now))
+
+ expired := *valid
+ expired.Expires = "1699999999"
+ require.Error(t, validateGoogleIDTokenInfo(&expired, "client-123", now))
+}
diff --git a/apps/backend/internal/module/studio/domain/domain.go b/apps/backend/internal/module/studio/domain/domain.go
index 73f6db1..25a6fe1 100644
--- a/apps/backend/internal/module/studio/domain/domain.go
+++ b/apps/backend/internal/module/studio/domain/domain.go
@@ -13,14 +13,15 @@ var (
ErrIllegalStatus = errors.New("illegal outbox status")
ErrNotDue = errors.New("step not due")
ErrRootBlocked = errors.New("root not published; reply blocked")
+ ErrLeaseLost = errors.New("outbox step lease lost")
ErrBadURL = errors.New("invalid external url")
ErrFormulaRemove = errors.New("generateFromFormula removed")
)
const (
- PersonaEmpty = "empty"
- PersonaAnalyzing = "analyzing"
- PersonaReady = "ready"
+ PersonaEmpty = "empty"
+ PersonaAnalyzing = "analyzing"
+ PersonaReady = "ready"
PlayDraft = "draft"
PlayReady = "ready"
@@ -47,9 +48,9 @@ const (
StepCancelled = "cancelled"
StepBlocked = "blocked"
- MentionPending = "pending"
- MentionReplied = "replied"
- MentionSkipped = "skipped"
+ MentionPending = "pending"
+ MentionReplied = "replied"
+ MentionSkipped = "skipped"
)
func NowNano() int64 { return time.Now().UTC().UnixNano() }
@@ -70,14 +71,14 @@ type Persona struct {
}
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"`
+ 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"`
}
@@ -128,42 +129,42 @@ type ExternalTarget struct {
}
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"`
+ 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"`
+ 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"`
+ 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)
@@ -176,15 +177,19 @@ type OutboxStep struct {
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"`
+ // Lease fields are internal worker fencing state. A unique owner is used for
+ // every claim so an expired publisher cannot finalize a reclaimed step.
+ LeaseOwner string `bson:"lease_owner,omitempty" json:"-"`
+ LeaseExpiresAt int64 `bson:"lease_expires_at,omitempty" json:"-"`
}
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"`
+ 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"`
diff --git a/apps/backend/internal/module/studio/domain/repository.go b/apps/backend/internal/module/studio/domain/repository.go
index 5c1c779..eb8845d 100644
--- a/apps/backend/internal/module/studio/domain/repository.go
+++ b/apps/backend/internal/module/studio/domain/repository.go
@@ -23,8 +23,14 @@ type Repository interface {
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)
+ // ClaimOutboxStep atomically moves one due scheduled (or expired publishing)
+ // step to publishing and assigns its fencing lease.
+ ClaimOutboxStep(ctx context.Context, bundleID, stepID, leaseOwner string, now, leaseExpiresAt int64) (*OutboxBundle, error)
+ RenewOutboxStepLease(ctx context.Context, bundleID, stepID, leaseOwner string, now, leaseExpiresAt int64) error
+ FinishOutboxStep(ctx context.Context, bundleID, stepID, leaseOwner string, step *OutboxStep, now int64) error
+ RecomputeOutboxStatus(ctx context.Context, bundleID string, now int64) error
+ RetryOutboxStep(ctx context.Context, bundleID, stepID string, now int64) (*OutboxBundle, error)
// OwnPosts
SaveOwnPost(ctx context.Context, p *OwnPost) error
diff --git a/apps/backend/internal/module/studio/publish/meta.go b/apps/backend/internal/module/studio/publish/meta.go
index 5da32c0..81ddc0d 100644
--- a/apps/backend/internal/module/studio/publish/meta.go
+++ b/apps/backend/internal/module/studio/publish/meta.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "net"
"net/http"
"net/url"
"strings"
@@ -26,9 +27,18 @@ func NewMeta(graphBase string) *MetaTransport {
if graphBase == "" {
graphBase = "https://graph.threads.net"
}
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.DialContext = dialPublicAddress
+ client := &http.Client{Timeout: 90 * time.Second, Transport: transport}
+ client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return fmt.Errorf("too many redirects")
+ }
+ return validatePublicHTTPURL(req.Context(), req.URL)
+ }
return &MetaTransport{
GraphBase: strings.TrimRight(graphBase, "/"),
- HTTPClient: &http.Client{Timeout: 90 * time.Second},
+ HTTPClient: client,
}
}
@@ -192,7 +202,14 @@ func publicImageURLs(raw []string) []string {
// preflightImageURL — 確認公開 URL 對匿名 GET 可讀(Meta crawler 同樣匿名)
func (t *MetaTransport) preflightImageURL(ctx context.Context, imageURL string) error {
- req, err := http.NewRequestWithContext(ctx, http.MethodHead, imageURL, nil)
+ parsed, err := url.Parse(imageURL)
+ if err != nil {
+ return err
+ }
+ if err := validatePublicHTTPURL(ctx, parsed); err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, parsed.String(), nil)
if err != nil {
return err
}
@@ -226,6 +243,53 @@ func (t *MetaTransport) preflightImageURL(ctx context.Context, imageURL string)
return nil
}
+func validatePublicHTTPURL(ctx context.Context, u *url.URL) error {
+ if u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
+ return fmt.Errorf("invalid public HTTP URL")
+ }
+ if u.User != nil {
+ return fmt.Errorf("URL credentials are not allowed")
+ }
+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, u.Hostname())
+ if err != nil {
+ return fmt.Errorf("cannot resolve image host: %w", err)
+ }
+ if len(addrs) == 0 {
+ return fmt.Errorf("image host has no address")
+ }
+ for _, addr := range addrs {
+ if !isPublicIP(addr.IP) {
+ return fmt.Errorf("image URL resolves to a non-public address")
+ }
+ }
+ return nil
+}
+
+func dialPublicAddress(ctx context.Context, network, address string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, err
+ }
+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, err
+ }
+ for _, addr := range addrs {
+ if !isPublicIP(addr.IP) {
+ return nil, fmt.Errorf("refusing to connect to a non-public address")
+ }
+ }
+ if len(addrs) == 0 {
+ return nil, fmt.Errorf("host has no address")
+ }
+ dialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
+ return dialer.DialContext(ctx, network, net.JoinHostPort(addrs[0].IP.String(), port))
+}
+
+func isPublicIP(ip net.IP) bool {
+ return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
+}
+
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 {
diff --git a/apps/backend/internal/module/studio/publish/meta_test.go b/apps/backend/internal/module/studio/publish/meta_test.go
new file mode 100644
index 0000000..0b3aa50
--- /dev/null
+++ b/apps/backend/internal/module/studio/publish/meta_test.go
@@ -0,0 +1,32 @@
+package publish
+
+import (
+ "context"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidatePublicHTTPURLRejectsPrivateTargets(t *testing.T) {
+ t.Parallel()
+ for _, raw := range []string{
+ "http://127.0.0.1/image.jpg",
+ "http://10.0.0.1/image.jpg",
+ "http://169.254.169.254/latest/meta-data",
+ "http://[::1]/image.jpg",
+ } {
+ u, err := url.Parse(raw)
+ require.NoError(t, err)
+ require.Error(t, validatePublicHTTPURL(context.Background(), u), raw)
+ }
+}
+
+func TestValidatePublicHTTPURLRejectsCredentialsAndUnsupportedSchemes(t *testing.T) {
+ t.Parallel()
+ for _, raw := range []string{"file:///etc/passwd", "https://user:pass@example.com/a.jpg"} {
+ u, err := url.Parse(raw)
+ require.NoError(t, err)
+ require.Error(t, validatePublicHTTPURL(context.Background(), u), raw)
+ }
+}
diff --git a/apps/backend/internal/module/studio/repository/memory.go b/apps/backend/internal/module/studio/repository/memory.go
index 0bf97ee..eaf24ac 100644
--- a/apps/backend/internal/module/studio/repository/memory.go
+++ b/apps/backend/internal/module/studio/repository/memory.go
@@ -9,14 +9,14 @@ import (
)
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
+ 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 {
@@ -190,13 +190,156 @@ func (s *MemoryStore) ListOutbox(_ context.Context, ownerUID int64) ([]*domain.O
func (s *MemoryStore) DeleteOutbox(_ context.Context, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
- if _, ok := s.outbox[id]; !ok {
+ b, ok := s.outbox[id]
+ if !ok {
return domain.ErrNotFound
}
+ for _, step := range b.Steps {
+ if step.Status == domain.StepPublishing {
+ return domain.ErrIllegalStatus
+ }
+ }
delete(s.outbox, id)
return nil
}
+func (s *MemoryStore) ClaimOutboxStep(_ context.Context, bundleID, stepID, leaseOwner string, now, leaseExpiresAt int64) (*domain.OutboxBundle, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, ok := s.outbox[bundleID]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ for i := range b.Steps {
+ step := &b.Steps[i]
+ if step.ID != stepID {
+ continue
+ }
+ due := step.Status == domain.StepScheduled && step.ScheduledAt <= now
+ stale := step.Status == domain.StepPublishing && step.LeaseExpiresAt <= now
+ if !due && !stale {
+ return nil, domain.ErrIllegalStatus
+ }
+ step.Status = domain.StepPublishing
+ step.Error = ""
+ step.LeaseOwner = leaseOwner
+ step.LeaseExpiresAt = leaseExpiresAt
+ b.Status = domain.OBActive
+ b.UpdatedAt = now
+ return copyOutbox(b), nil
+ }
+ return nil, domain.ErrNotFound
+}
+
+func (s *MemoryStore) RenewOutboxStepLease(_ context.Context, bundleID, stepID, leaseOwner string, now, leaseExpiresAt int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, ok := s.outbox[bundleID]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ for i := range b.Steps {
+ step := &b.Steps[i]
+ if step.ID == stepID && step.Status == domain.StepPublishing && step.LeaseOwner == leaseOwner && step.LeaseExpiresAt > now {
+ step.LeaseExpiresAt = leaseExpiresAt
+ b.UpdatedAt = now
+ return nil
+ }
+ }
+ return domain.ErrLeaseLost
+}
+
+func (s *MemoryStore) FinishOutboxStep(_ context.Context, bundleID, stepID, leaseOwner string, result *domain.OutboxStep, now int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, ok := s.outbox[bundleID]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ for i := range b.Steps {
+ step := &b.Steps[i]
+ if step.ID != stepID || step.Status != domain.StepPublishing || step.LeaseOwner != leaseOwner || step.LeaseExpiresAt <= now {
+ continue
+ }
+ *step = *result
+ step.LeaseOwner = ""
+ step.LeaseExpiresAt = 0
+ if step.Kind == domain.StepRoot {
+ for j := range b.Steps {
+ reply := &b.Steps[j]
+ if reply.Kind != domain.StepReply || reply.Status == domain.StepPublished || reply.Status == domain.StepPublishing {
+ continue
+ }
+ if step.Status == domain.StepFailed {
+ reply.Status, reply.Error = domain.StepBlocked, "waiting for root success"
+ } else if step.Status == domain.StepPublished && reply.Status == domain.StepBlocked {
+ reply.Status, reply.Error = domain.StepScheduled, ""
+ }
+ }
+ }
+ b.UpdatedAt = now
+ return nil
+ }
+ return domain.ErrLeaseLost
+}
+
+func (s *MemoryStore) RecomputeOutboxStatus(_ context.Context, bundleID string, now int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, ok := s.outbox[bundleID]
+ if !ok {
+ return domain.ErrNotFound
+ }
+ b.Status = outboxStatus(b.Steps)
+ b.UpdatedAt = now
+ return nil
+}
+
+func (s *MemoryStore) RetryOutboxStep(_ context.Context, bundleID, stepID string, now int64) (*domain.OutboxBundle, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ b, ok := s.outbox[bundleID]
+ if !ok {
+ return nil, domain.ErrNotFound
+ }
+ for i := range b.Steps {
+ step := &b.Steps[i]
+ if step.ID != stepID {
+ continue
+ }
+ if step.Status != domain.StepFailed {
+ return nil, domain.ErrIllegalStatus
+ }
+ step.Status, step.Error, step.ScheduledAt = domain.StepScheduled, "", now
+ step.LeaseOwner, step.LeaseExpiresAt = "", 0
+ b.Status, b.UpdatedAt = domain.OBScheduling, now
+ return copyOutbox(b), nil
+ }
+ return nil, domain.ErrNotFound
+}
+
+func outboxStatus(steps []domain.OutboxStep) string {
+ if len(steps) == 0 {
+ return domain.OBCancelled
+ }
+ allPublished, anyFailed, anyPublishing := true, false, false
+ for _, step := range steps {
+ allPublished = allPublished && step.Status == domain.StepPublished
+ anyFailed = anyFailed || step.Status == domain.StepFailed
+ anyPublishing = anyPublishing || step.Status == domain.StepPublishing
+ }
+ if allPublished {
+ return domain.OBCompleted
+ }
+ if anyFailed {
+ return domain.OBPartial
+ }
+ if anyPublishing {
+ return domain.OBActive
+ }
+ return domain.OBScheduling
+}
+
func (s *MemoryStore) ListAllOutbox(_ context.Context) ([]*domain.OutboxBundle, error) {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/apps/backend/internal/module/studio/repository/mongo.go b/apps/backend/internal/module/studio/repository/mongo.go
index f82e537..757ccff 100644
--- a/apps/backend/internal/module/studio/repository/mongo.go
+++ b/apps/backend/internal/module/studio/repository/mongo.go
@@ -8,6 +8,7 @@ import (
"github.com/zeromicro/go-zero/core/stores/mon"
"go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
@@ -147,16 +148,132 @@ func (s *MonStore) ListOutbox(ctx context.Context, ownerUID int64) ([]*domain.Ou
}
func (s *MonStore) DeleteOutbox(ctx context.Context, id string) error {
- res, err := s.outbox.DeleteOne(ctx, bson.M{"_id": id})
+ res, err := s.outbox.DeleteOne(ctx, bson.M{
+ "_id": id, "steps": bson.M{"$not": bson.M{"$elemMatch": bson.M{"status": domain.StepPublishing}}},
+ })
if err != nil {
return err
}
if res == 0 {
+ if _, getErr := s.GetOutbox(ctx, id); getErr == nil {
+ return domain.ErrIllegalStatus
+ } else {
+ return getErr
+ }
+ }
+ return nil
+}
+
+func (s *MonStore) ClaimOutboxStep(ctx context.Context, bundleID, stepID, leaseOwner string, now, leaseExpiresAt int64) (*domain.OutboxBundle, error) {
+ claimable := bson.M{"id": stepID, "$or": bson.A{
+ bson.M{"status": domain.StepScheduled, "scheduled_at": bson.M{"$lte": now}},
+ bson.M{"status": domain.StepPublishing, "$or": bson.A{
+ bson.M{"lease_expires_at": bson.M{"$lte": now}}, bson.M{"lease_expires_at": bson.M{"$exists": false}},
+ }},
+ }}
+ filter := bson.M{"_id": bundleID, "steps": bson.M{"$elemMatch": claimable}}
+ update := bson.M{"$set": bson.M{
+ "steps.$.status": domain.StepPublishing, "steps.$.error": "",
+ "steps.$.lease_owner": leaseOwner, "steps.$.lease_expires_at": leaseExpiresAt,
+ "status": domain.OBActive, "updated_at": now,
+ }}
+ var b domain.OutboxBundle
+ err := s.outbox.FindOneAndUpdate(ctx, &b, filter, update, options.FindOneAndUpdate().SetReturnDocument(options.After))
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrIllegalStatus
+ }
+ return &b, err
+}
+
+func (s *MonStore) RenewOutboxStepLease(ctx context.Context, bundleID, stepID, leaseOwner string, now, leaseExpiresAt int64) error {
+ filter := ownedPublishingStep(bundleID, stepID, leaseOwner, now)
+ res, err := s.outbox.UpdateOne(ctx, filter, bson.M{"$set": bson.M{
+ "steps.$.lease_expires_at": leaseExpiresAt, "updated_at": now,
+ }})
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return domain.ErrLeaseLost
+ }
+ return nil
+}
+
+func (s *MonStore) FinishOutboxStep(ctx context.Context, bundleID, stepID, leaseOwner string, step *domain.OutboxStep, now int64) error {
+ filter := ownedPublishingStep(bundleID, stepID, leaseOwner, now)
+ set := bson.M{
+ "steps.$[claimed].status": step.Status, "steps.$[claimed].error": step.Error,
+ "steps.$[claimed].published_at": step.PublishedAt, "steps.$[claimed].media_id": step.MediaID,
+ "steps.$[claimed].image_urls": step.ImageURLs, "updated_at": now,
+ }
+ unset := bson.M{"steps.$[claimed].lease_owner": "", "steps.$[claimed].lease_expires_at": ""}
+ filters := options.ArrayFilters{Filters: []interface{}{bson.M{
+ "claimed.id": stepID, "claimed.status": domain.StepPublishing, "claimed.lease_owner": leaseOwner,
+ }}}
+ if step.Kind == domain.StepRoot && (step.Status == domain.StepFailed || step.Status == domain.StepPublished) {
+ replyFilter := bson.M{"reply.kind": domain.StepReply, "reply.status": bson.M{"$nin": bson.A{domain.StepPublished, domain.StepPublishing}}}
+ if step.Status == domain.StepFailed {
+ set["steps.$[reply].status"], set["steps.$[reply].error"] = domain.StepBlocked, "waiting for root success"
+ } else {
+ replyFilter["reply.status"] = domain.StepBlocked
+ set["steps.$[reply].status"], set["steps.$[reply].error"] = domain.StepScheduled, ""
+ }
+ filters.Filters = append(filters.Filters, replyFilter)
+ }
+ res, err := s.outbox.UpdateOne(ctx, filter, bson.M{"$set": set, "$unset": unset}, options.Update().SetArrayFilters(filters))
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return domain.ErrLeaseLost
+ }
+ return nil
+}
+
+func ownedPublishingStep(bundleID, stepID, leaseOwner string, now int64) bson.M {
+ return bson.M{"_id": bundleID, "steps": bson.M{"$elemMatch": bson.M{
+ "id": stepID, "status": domain.StepPublishing, "lease_owner": leaseOwner,
+ "lease_expires_at": bson.M{"$gt": now},
+ }}}
+}
+
+func (s *MonStore) RecomputeOutboxStatus(ctx context.Context, bundleID string, now int64) error {
+ statuses := bson.M{"$map": bson.M{"input": "$steps", "as": "step", "in": "$$step.status"}}
+ pipeline := mongo.Pipeline{bson.D{{Key: "$set", Value: bson.M{
+ "status": bson.M{"$switch": bson.M{"branches": bson.A{
+ bson.M{"case": bson.M{"$eq": bson.A{statuses, bson.A{}}}, "then": domain.OBCancelled},
+ bson.M{"case": bson.M{"$allElementsTrue": bson.A{bson.M{"$map": bson.M{"input": "$steps", "as": "step", "in": bson.M{"$eq": bson.A{"$$step.status", domain.StepPublished}}}}}}, "then": domain.OBCompleted},
+ bson.M{"case": bson.M{"$in": bson.A{domain.StepFailed, statuses}}, "then": domain.OBPartial},
+ bson.M{"case": bson.M{"$in": bson.A{domain.StepPublishing, statuses}}, "then": domain.OBActive},
+ }, "default": domain.OBScheduling}}, "updated_at": now,
+ }}}}
+ res, err := s.outbox.UpdateOne(ctx, bson.M{"_id": bundleID}, pipeline)
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
return domain.ErrNotFound
}
return nil
}
+func (s *MonStore) RetryOutboxStep(ctx context.Context, bundleID, stepID string, now int64) (*domain.OutboxBundle, error) {
+ filter := bson.M{"_id": bundleID, "steps": bson.M{"$elemMatch": bson.M{"id": stepID, "status": domain.StepFailed}}}
+ update := bson.M{
+ "$set": bson.M{
+ "steps.$.status": domain.StepScheduled, "steps.$.error": "", "steps.$.scheduled_at": now,
+ "status": domain.OBScheduling, "updated_at": now,
+ },
+ "$unset": bson.M{"steps.$.lease_owner": "", "steps.$.lease_expires_at": ""},
+ }
+ var b domain.OutboxBundle
+ err := s.outbox.FindOneAndUpdate(ctx, &b, filter, update, options.FindOneAndUpdate().SetReturnDocument(options.After))
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrIllegalStatus
+ }
+ return &b, err
+}
+
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))
diff --git a/apps/backend/internal/module/studio/repository/outbox_memory_test.go b/apps/backend/internal/module/studio/repository/outbox_memory_test.go
new file mode 100644
index 0000000..a6f6dc2
--- /dev/null
+++ b/apps/backend/internal/module/studio/repository/outbox_memory_test.go
@@ -0,0 +1,81 @@
+package repository
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "apps/backend/internal/module/studio/domain"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestOutboxClaimIsAtomicPerStep(t *testing.T) {
+ repo := NewMemory()
+ now := domain.NowNano()
+ require.NoError(t, repo.SaveOutbox(context.Background(), &domain.OutboxBundle{
+ ID: "bundle", Steps: []domain.OutboxStep{{ID: "step", Status: domain.StepScheduled, ScheduledAt: now}},
+ }))
+
+ const workers = 16
+ start := make(chan struct{})
+ results := make(chan error, workers)
+ var wg sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func(owner string) {
+ defer wg.Done()
+ <-start
+ _, err := repo.ClaimOutboxStep(context.Background(), "bundle", "step", owner, now, now+int64(time.Minute))
+ results <- err
+ }(time.Duration(i).String())
+ }
+ close(start)
+ wg.Wait()
+ close(results)
+
+ claimed := 0
+ for err := range results {
+ if err == nil {
+ claimed++
+ } else {
+ require.ErrorIs(t, err, domain.ErrIllegalStatus)
+ }
+ }
+ require.Equal(t, 1, claimed)
+}
+
+func TestOutboxExpiredClaimFencesStaleOwner(t *testing.T) {
+ repo := NewMemory()
+ now := domain.NowNano()
+ require.NoError(t, repo.SaveOutbox(context.Background(), &domain.OutboxBundle{
+ ID: "bundle", Steps: []domain.OutboxStep{{ID: "step", Kind: domain.StepRoot, Status: domain.StepScheduled, ScheduledAt: now}},
+ }))
+ first, err := repo.ClaimOutboxStep(context.Background(), "bundle", "step", "worker-a", now, now+10)
+ require.NoError(t, err)
+ second, err := repo.ClaimOutboxStep(context.Background(), "bundle", "step", "worker-b", now+11, now+int64(time.Minute))
+ require.NoError(t, err)
+
+ staleResult := first.Steps[0]
+ staleResult.Status = domain.StepPublished
+ err = repo.FinishOutboxStep(context.Background(), "bundle", "step", "worker-a", &staleResult, now+12)
+ require.ErrorIs(t, err, domain.ErrLeaseLost)
+
+ result := second.Steps[0]
+ result.Status, result.MediaID = domain.StepPublished, "media-b"
+ require.NoError(t, repo.FinishOutboxStep(context.Background(), "bundle", "step", "worker-b", &result, now+12))
+ got, err := repo.GetOutbox(context.Background(), "bundle")
+ require.NoError(t, err)
+ require.Equal(t, "media-b", got.Steps[0].MediaID)
+}
+
+func TestDeleteOutboxRejectsPublishingStep(t *testing.T) {
+ repo := NewMemory()
+ require.NoError(t, repo.SaveOutbox(context.Background(), &domain.OutboxBundle{
+ ID: "bundle", Steps: []domain.OutboxStep{{ID: "step", Status: domain.StepPublishing}},
+ }))
+ require.ErrorIs(t, repo.DeleteOutbox(context.Background(), "bundle"), domain.ErrIllegalStatus)
+ _, err := repo.GetOutbox(context.Background(), "bundle")
+ require.NoError(t, err)
+}
diff --git a/apps/backend/internal/module/studio/usecase/m4_test.go b/apps/backend/internal/module/studio/usecase/m4_test.go
index 495d6cc..7200ff2 100644
--- a/apps/backend/internal/module/studio/usecase/m4_test.go
+++ b/apps/backend/internal/module/studio/usecase/m4_test.go
@@ -22,6 +22,21 @@ type memAccounts struct {
byID map[string]*threadsDomain.Account
}
+type blockingPublishTransport struct {
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (t *blockingPublishTransport) Publish(ctx context.Context, _ domain.PublishRequest) (*domain.PublishResult, error) {
+ close(t.entered)
+ select {
+ case <-t.release:
+ return &domain.PublishResult{MediaID: "renewed-media"}, nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
func (m *memAccounts) Get(_ context.Context, id string) (*threadsDomain.Account, error) {
a, ok := m.byID[id]
if !ok {
@@ -242,7 +257,7 @@ func TestPL_01_SaveGet(t *testing.T) {
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: "主貼"}},
+ Steps: []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "主貼"}},
ScheduleStartAt: domain.NowNano(),
})
require.NoError(t, err)
@@ -642,6 +657,35 @@ func TestOB_10_RemoveStopsWorker(t *testing.T) {
require.Equal(t, 0, tp.CallCount())
}
+func TestOB_PublishRenewsStepLease(t *testing.T) {
+ repo := repository.NewMemory()
+ transport := &blockingPublishTransport{entered: make(chan struct{}), release: make(chan struct{})}
+ svc := usecase.New(repo, transport)
+ svc.OutboxLeaseDuration = 90 * time.Millisecond
+ now := domain.NowNano()
+ require.NoError(t, repo.SaveOutbox(context.Background(), &domain.OutboxBundle{
+ ID: "lease-bundle", Steps: []domain.OutboxStep{{ID: "lease-step", Kind: domain.StepRoot, Status: domain.StepScheduled, ScheduledAt: now}},
+ }))
+ done := make(chan error, 1)
+ go func() {
+ _, err := svc.ProcessDueSteps(context.Background(), now)
+ done <- err
+ }()
+ <-transport.entered
+ claimed, err := repo.GetOutbox(context.Background(), "lease-bundle")
+ require.NoError(t, err)
+ initialExpiry := claimed.Steps[0].LeaseExpiresAt
+ require.Eventually(t, func() bool {
+ got, getErr := repo.GetOutbox(context.Background(), "lease-bundle")
+ return getErr == nil && got.Steps[0].LeaseExpiresAt > initialExpiry
+ }, time.Second, 10*time.Millisecond)
+ close(transport.release)
+ require.NoError(t, <-done)
+ got, err := repo.GetOutbox(context.Background(), "lease-bundle")
+ require.NoError(t, err)
+ require.Equal(t, domain.StepPublished, got.Steps[0].Status)
+}
+
func TestOB_11_CrossUid(t *testing.T) {
svc, _, acc := newStudio()
uid1, uid2 := int64(4_003_011), int64(4_003_012)
diff --git a/apps/backend/internal/module/studio/usecase/service.go b/apps/backend/internal/module/studio/usecase/service.go
index 11a6984..dfb08e0 100644
--- a/apps/backend/internal/module/studio/usecase/service.go
+++ b/apps/backend/internal/module/studio/usecase/service.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/json"
+ "errors"
"fmt"
"net/url"
"strings"
@@ -49,7 +50,7 @@ type ThreadsMediaSource interface {
// Fetched* 與 provider 解耦,避免 studio 依賴 provider 包。
type FetchedThread struct {
ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string
- PublishedAt int64 // unix ns
+ PublishedAt int64 // unix ns
}
type FetchedInsights struct {
@@ -67,8 +68,8 @@ type FetchedReply struct {
// FetchedMention — Graph /{user-id}/mentions
type FetchedMention struct {
ID, Text, Username, Permalink, RootPostID, ParentID string
- IsReply, IsQuotePost bool
- PublishedAt int64
+ IsReply, IsQuotePost bool
+ PublishedAt int64
}
// AIKeySource resolves provider/model/apiKey for a member (settings + platform).
@@ -107,10 +108,16 @@ type Service struct {
StoragePublicBase string
// optional key resolver path via Usage.PrepareCall + Static/Settings
KeyModeDefault string // if Usage nil, use this for tests
+ OutboxWorkerID string
+ // OutboxLeaseDuration is configurable for focused lease-renewal tests.
+ OutboxLeaseDuration time.Duration
}
func New(repo domain.Repository, transport publish.Transport) *Service {
- return &Service{Repo: repo, Transport: transport}
+ return &Service{
+ Repo: repo, Transport: transport,
+ OutboxWorkerID: "studio-" + uuid.NewString(), OutboxLeaseDuration: 3 * time.Minute,
+ }
}
// ---------- Personas (PE) ----------
@@ -1110,11 +1117,15 @@ func (s *Service) RemoveOutbox(ctx context.Context, ownerUID int64, id string) e
if err != nil {
return err
}
- // 未發出/失敗取消:清掉暫存圖
+ // Delete has a repository-level no-publishing guard. Clean up only after it
+ // succeeds, otherwise a publisher may still need these URLs.
+ if err := s.Repo.DeleteOutbox(ctx, id); err != nil {
+ return err
+ }
for i := range b.Steps {
s.cleanupEphemeralImages(ctx, b.Steps[i].ImageURLs)
}
- return s.Repo.DeleteOutbox(ctx, id)
+ return nil
}
func (s *Service) RetryStep(ctx context.Context, ownerUID int64, bundleID, stepID string) (*domain.OutboxBundle, error) {
@@ -1143,24 +1154,7 @@ func (s *Service) RetryStep(ctx context.Context, ownerUID int64, bundleID, stepI
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
+ return s.Repo.RetryOutboxStep(ctx, bundleID, stepID, domain.NowNano())
}
// ProcessDueSteps is the worker tick — publishes via Transport only.
@@ -1183,138 +1177,173 @@ func (s *Service) ProcessDueSteps(ctx context.Context, now int64) (published int
}
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
+ claimable := st.Status == domain.StepScheduled && st.ScheduledAt <= now
+ stale := st.Status == domain.StepPublishing && st.LeaseExpiresAt <= now
+ if !claimable && !stale {
continue
}
+ root := findRoot(b)
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
+ if root != nil && root.Status != domain.StepPublished {
continue
}
}
- // publish via transport
+
+ leaseDuration := s.OutboxLeaseDuration
+ if leaseDuration <= 0 {
+ leaseDuration = 3 * time.Minute
+ }
+ claimOwner := s.OutboxWorkerID + ":" + uuid.NewString()
+ claimNow := domain.NowNano()
+ claimed, claimErr := s.Repo.ClaimOutboxStep(ctx, b.ID, st.ID, claimOwner, now, claimNow+int64(leaseDuration))
+ if claimErr != nil {
+ if claimErr == domain.ErrIllegalStatus || claimErr == domain.ErrNotFound {
+ continue
+ }
+ return count, claimErr
+ }
+ if stale {
+ logx.Infof("outbox reclaimed expired publishing lease step=%s bundle=%s", st.ID, b.ID)
+ }
+ claimedStep := findOutboxStep(claimed, st.ID)
+ if claimedStep == nil {
+ return count, domain.ErrNotFound
+ }
+ result := *claimedStep
+ result.LeaseOwner, result.LeaseExpiresAt = "", 0
+ fail := func(message string) error {
+ result.Status, result.Error = domain.StepFailed, message
+ finishedAt := domain.NowNano()
+ if err := s.Repo.FinishOutboxStep(ctx, b.ID, result.ID, claimOwner, &result, finishedAt); err != nil {
+ return err
+ }
+ return s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt)
+ }
if s.Transport == nil {
- st.Status = domain.StepFailed
- st.Error = "publish transport not configured"
+ if err := fail("publish transport not configured"); err != nil {
+ return count, err
+ }
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)
+ acc, aerr := s.Accounts.Get(ctx, result.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)
+ if err := fail("account not usable"); err != nil {
+ return count, err
+ }
continue
}
- if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" {
+ if t, terr := s.Accounts.AccessToken(ctx, acc); terr != nil {
+ if err := fail(terr.Error()); err != nil {
+ return count, err
+ }
+ continue
+ } else if t != "" {
token = t
}
}
- replyTo := st.ReplyTo
- if replyTo == "" && st.Kind == domain.StepReply {
+ root = findRoot(claimed)
+ replyTo := result.ReplyTo
+ if replyTo == "" && result.Kind == domain.StepReply {
if root != nil && root.MediaID != "" {
replyTo = root.MediaID
- } else if b.ReplyToMediaID != "" {
- replyTo = b.ReplyToMediaID
+ } else if claimed.ReplyToMediaID != "" {
+ replyTo = claimed.ReplyToMediaID
}
}
- res, perr := s.Transport.Publish(ctx, domain.PublishRequest{
+ request := domain.PublishRequest{
AccessToken: token,
- AccountID: st.AccountID,
- Text: st.Text,
+ AccountID: result.AccountID,
+ Text: result.Text,
ReplyTo: replyTo,
- ImageURLs: st.ImageURLs,
+ ImageURLs: result.ImageURLs,
// 僅主貼帶話題標籤
TopicTag: func() string {
- if st.Kind == domain.StepRoot {
- return st.TopicTag
+ if result.Kind == domain.StepRoot {
+ return result.TopicTag
}
return ""
}(),
- })
+ }
+ res, perr := s.publishWithLease(ctx, b.ID, result.ID, claimOwner, leaseDuration, request)
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"
- }
- }
+ if errors.Is(perr, domain.ErrLeaseLost) {
+ return count, perr
+ }
+ if err := fail(perr.Error()); err != nil {
+ return count, err
}
} else {
- st.Status = domain.StepPublished
- st.Error = ""
- st.PublishedAt = domain.NowNano()
+ result.Status, result.Error = domain.StepPublished, ""
+ result.PublishedAt = domain.NowNano()
if res != nil {
- st.MediaID = res.MediaID
+ result.MediaID = res.MediaID
}
- // Meta 已抓完圖:刪除 temp/* 暫存,outbox 不再保留 image_urls
- if len(st.ImageURLs) > 0 {
- s.cleanupEphemeralImages(ctx, st.ImageURLs)
- st.ImageURLs = nil
+ images := append([]string(nil), result.ImageURLs...)
+ result.ImageURLs = nil
+ finishedAt := domain.NowNano()
+ if err := s.Repo.FinishOutboxStep(ctx, b.ID, result.ID, claimOwner, &result, finishedAt); err != nil {
+ // The remote result may have succeeded. Never let a stale lease write
+ // final state; a reclaim can duplicate because Meta has no idempotency key.
+ return count, err
}
+ if err := s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt); err != nil {
+ return count, err
+ }
+ s.cleanupEphemeralImages(ctx, images)
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 = ""
- }
+ }
+ fresh, getErr := s.Repo.GetOutbox(ctx, b.ID)
+ if getErr != nil {
+ return count, getErr
+ }
+ b = fresh
+ }
+ return count, nil
+}
+
+func (s *Service) publishWithLease(ctx context.Context, bundleID, stepID, leaseOwner string, leaseDuration time.Duration, request domain.PublishRequest) (*domain.PublishResult, error) {
+ publishCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ renewEvery := leaseDuration / 3
+ if renewEvery <= 0 {
+ renewEvery = time.Millisecond
+ }
+ renewErr := make(chan error, 1)
+ done := make(chan struct{})
+ stopped := make(chan struct{})
+ go func() {
+ defer close(stopped)
+ ticker := time.NewTicker(renewEvery)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-ticker.C:
+ now := domain.NowNano()
+ if err := s.Repo.RenewOutboxStepLease(publishCtx, bundleID, stepID, leaseOwner, now, now+int64(leaseDuration)); err != nil {
+ renewErr <- err
+ cancel()
+ return
}
}
}
- 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
+ }()
+ res, err := s.Transport.Publish(publishCtx, request)
+ close(done)
+ <-stopped
+ select {
+ case leaseErr := <-renewErr:
+ return res, fmt.Errorf("%w: renew failed: %v", domain.ErrLeaseLost, leaseErr)
+ default:
+ return res, err
}
- return count, nil
}
// ---------- Compose (CP) ----------
@@ -2655,7 +2684,7 @@ func playToOutbox(ownerUID int64, play *domain.Play) *domain.OutboxBundle {
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,
+ Status: domain.StepScheduled, ScheduledAt: cursor, ReplyTo: replyTo,
})
}
title := play.Title
@@ -2719,6 +2748,15 @@ func findRoot(b *domain.OutboxBundle) *domain.OutboxStep {
return nil
}
+func findOutboxStep(b *domain.OutboxBundle, stepID string) *domain.OutboxStep {
+ for i := range b.Steps {
+ if b.Steps[i].ID == stepID {
+ return &b.Steps[i]
+ }
+ }
+ return nil
+}
+
func recomputeBundle(steps []domain.OutboxStep) string {
if len(steps) == 0 {
return domain.OBCancelled
diff --git a/apps/backend/internal/module/usage/domain/repository.go b/apps/backend/internal/module/usage/domain/repository.go
index ff1d6e8..da79415 100644
--- a/apps/backend/internal/module/usage/domain/repository.go
+++ b/apps/backend/internal/module/usage/domain/repository.go
@@ -3,9 +3,15 @@ package domain
import "context"
type Repository interface {
+ // ReservePlatform atomically guards both limits and increments one monthly counter document.
+ // A negative limit means unlimited. BYOK must never call this operation.
+ ReservePlatform(ctx context.Context, uid int64, monthKey, meter string, cost, totalLimit, meterLimit int) error
+ GetMonthlyCounter(ctx context.Context, uid int64, monthKey string) (*MonthlyCounter, error)
+
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)
+ ListEventsRange(ctx context.Context, fromNano, toExclusiveNano int64) ([]*Event, error)
GetPrefs(ctx context.Context, uid int64) (*MemberPrefs, error)
SavePrefs(ctx context.Context, p *MemberPrefs) error
diff --git a/apps/backend/internal/module/usage/domain/usage.go b/apps/backend/internal/module/usage/domain/usage.go
index e9ac8c0..96dd35e 100644
--- a/apps/backend/internal/module/usage/domain/usage.go
+++ b/apps/backend/internal/module/usage/domain/usage.go
@@ -30,6 +30,8 @@ var (
// 平台 key 無容量/限流:僅 BYOK 可繼續(不扣平台點)
ErrPlatformCapacity = errors.New("usage.err.platformCapacity")
ErrInvalidPlan = errors.New("invalid plan")
+ ErrInvalidRange = errors.New("invalid usage analytics range")
+ ErrPurchaseDisabled = errors.New("self-service plan purchase is disabled; use billing checkout")
)
type Event struct {
@@ -44,11 +46,30 @@ type Event struct {
CreatedAt int64 `bson:"created_at" json:"created_at"`
}
+type MonthlyCounter struct {
+ ID string `bson:"_id" json:"id"`
+ UID int64 `bson:"uid" json:"uid"`
+ MonthKey string `bson:"month_key" json:"month_key"`
+ TotalCredits int `bson:"total_credits" json:"total_credits"`
+ ByMeter map[string]int `bson:"by_meter" json:"by_meter"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_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"`
+ UID int64 `bson:"uid" json:"uid"`
+ PlanID string `bson:"plan_id" json:"plan_id"`
+ Unlimited bool `bson:"unlimited" json:"unlimited"`
+ AdminOverride bool `bson:"admin_override" json:"admin_override"`
+ PaidPlanID string `bson:"paid_plan_id,omitempty" json:"paid_plan_id,omitempty"`
+ SubscriptionStatus string `bson:"subscription_status,omitempty" json:"subscription_status,omitempty"`
+ StripeCustomerID string `bson:"stripe_customer_id,omitempty" json:"stripe_customer_id,omitempty"`
+ StripeSubscriptionID string `bson:"stripe_subscription_id,omitempty" json:"stripe_subscription_id,omitempty"`
+ CurrentPeriodStart int64 `bson:"current_period_start,omitempty" json:"current_period_start,omitempty"`
+ CurrentPeriodEnd int64 `bson:"current_period_end,omitempty" json:"current_period_end,omitempty"`
+ CancelAtPeriodEnd bool `bson:"cancel_at_period_end" json:"cancel_at_period_end"`
+ BillingEventID string `bson:"billing_event_id,omitempty" json:"-"`
+ BillingEventCreatedAt int64 `bson:"billing_event_created_at,omitempty" json:"-"`
+ UpdatedAt int64 `bson:"updated_at" json:"updated_at"`
}
type Purchase struct {
@@ -152,16 +173,70 @@ type MonthSummary struct {
}
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"`
+ 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"`
+ MonthKey string `json:"month_key"`
+ PlatformCreditsUsed int `json:"platform_credits_used"`
+ ByokCallCount int `json:"byok_call_count"`
+ Members []TenantRow `json:"members"`
+}
+
+type AnalyticsMember struct {
+ UID int64
+ Email string
+ DisplayName string
+}
+
+type AnalyticsQuery struct {
+ Granularity string
+ From time.Time
+ To time.Time
+}
+
+type AnalyticsBucket struct {
+ Key string
+ Label string
+ PurchasedCredits int
+ ConsumedCredits int
+ AICalls int
+ SearchCalls int
+ ByokCallCount int
+}
+
+type AnalyticsMemberRow struct {
+ UID int64
+ Email string
+ DisplayName string
+ Unlimited bool
+ PlanID string
+ PurchasedCredits int
+ TotalCredits int
+ AICalls int
+ SearchCalls int
+ ByokCallCount int
+ RemainingCredits int
+ Pct int
+}
+
+type TenantAnalytics struct {
+ Granularity string
+ From string
+ To string
+ RangeLabel string
+ PurchasedCredits int
+ ConsumedCredits int
+ RemainingCredits int
+ Pct int
+ AICalls int
+ SearchCalls int
+ ByMeter map[string]MeterCount
+ Byok ByokBlock
+ Series []AnalyticsBucket
+ Members []AnalyticsMemberRow
}
diff --git a/apps/backend/internal/module/usage/repository/memory.go b/apps/backend/internal/module/usage/repository/memory.go
index 71b42c7..f0305b3 100644
--- a/apps/backend/internal/module/usage/repository/memory.go
+++ b/apps/backend/internal/module/usage/repository/memory.go
@@ -2,6 +2,7 @@ package repository
import (
"context"
+ "fmt"
"sync"
"apps/backend/internal/module/usage/domain"
@@ -10,16 +11,68 @@ import (
type MemoryStore struct {
mu sync.Mutex
events []*domain.Event
+ counters map[string]*domain.MonthlyCounter
prefs map[int64]*domain.MemberPrefs
purchases []*domain.Purchase
}
func NewMemory() *MemoryStore {
return &MemoryStore{
- prefs: map[int64]*domain.MemberPrefs{},
+ counters: map[string]*domain.MonthlyCounter{},
+ prefs: map[int64]*domain.MemberPrefs{},
}
}
+func memoryCounterID(uid int64, monthKey string) string {
+ return fmt.Sprintf("%d:%s", uid, monthKey)
+}
+
+func (s *MemoryStore) ReservePlatform(_ context.Context, uid int64, monthKey, meter string, cost, totalLimit, meterLimit int) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ id := memoryCounterID(uid, monthKey)
+ counter := s.counters[id]
+ if counter == nil {
+ counter = &domain.MonthlyCounter{ID: id, UID: uid, MonthKey: monthKey, ByMeter: map[string]int{}}
+ for _, event := range s.events {
+ if event.UID == uid && event.MonthKey == monthKey && event.KeyMode == domain.KeyModePlatform {
+ credits := event.Credits
+ if minimum := domain.MeterCost(event.Meter); credits < minimum {
+ credits = minimum
+ }
+ counter.TotalCredits += credits
+ counter.ByMeter[event.Meter] += credits
+ }
+ }
+ s.counters[id] = counter
+ }
+ if totalLimit >= 0 && counter.TotalCredits+cost > totalLimit {
+ return domain.ErrQuotaExceeded
+ }
+ if meterLimit >= 0 && counter.ByMeter[meter]+cost > meterLimit {
+ return domain.ErrMeterCapExceeded
+ }
+ counter.TotalCredits += cost
+ counter.ByMeter[meter] += cost
+ counter.UpdatedAt = domain.NowNano()
+ return nil
+}
+
+func (s *MemoryStore) GetMonthlyCounter(_ context.Context, uid int64, monthKey string) (*domain.MonthlyCounter, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ counter := s.counters[memoryCounterID(uid, monthKey)]
+ if counter == nil {
+ return nil, domain.ErrNotFound
+ }
+ copyCounter := *counter
+ copyCounter.ByMeter = make(map[string]int, len(counter.ByMeter))
+ for meter, credits := range counter.ByMeter {
+ copyCounter.ByMeter[meter] = credits
+ }
+ return ©Counter, nil
+}
+
func (s *MemoryStore) InsertEvent(_ context.Context, e *domain.Event) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -70,6 +123,20 @@ func (s *MemoryStore) ListEventsAll(_ context.Context, monthKey string, limit in
return out, nil
}
+func (s *MemoryStore) ListEventsRange(_ context.Context, fromNano, toExclusiveNano int64) ([]*domain.Event, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]*domain.Event, 0)
+ for _, event := range s.events {
+ if event.CreatedAt < fromNano || event.CreatedAt >= toExclusiveNano {
+ continue
+ }
+ cp := *event
+ out = append(out, &cp)
+ }
+ return out, nil
+}
+
func (s *MemoryStore) GetPrefs(_ context.Context, uid int64) (*domain.MemberPrefs, error) {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/apps/backend/internal/module/usage/repository/mongo.go b/apps/backend/internal/module/usage/repository/mongo.go
index e55c2b8..33ff2db 100644
--- a/apps/backend/internal/module/usage/repository/mongo.go
+++ b/apps/backend/internal/module/usage/repository/mongo.go
@@ -2,17 +2,20 @@ package repository
import (
"context"
+ "fmt"
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"
"go.mongodb.org/mongo-driver/mongo/options"
)
type MonStore struct {
events *mon.Model
+ counters *mon.Model
prefs *mon.Model
purchases *mon.Model
}
@@ -21,11 +24,116 @@ func NewMonStore(uri, database string) *MonStore {
uri = libmongo.MustMongoURI(uri)
return &MonStore{
events: mon.MustNewModel(uri, database, "usage_events"),
+ counters: mon.MustNewModel(uri, database, "usage_monthly_counters"),
prefs: mon.MustNewModel(uri, database, "usage_prefs"),
purchases: mon.MustNewModel(uri, database, "usage_purchases"),
}
}
+func monthlyCounterID(uid int64, monthKey string) string {
+ return fmt.Sprintf("%d:%s", uid, monthKey)
+}
+
+func meterCounterField(meter string) (string, error) {
+ switch meter {
+ case domain.MeterAICopy, domain.MeterAIResearch, domain.MeterWebSearch, domain.MeterAIImage:
+ return "by_meter." + meter, nil
+ default:
+ return "", fmt.Errorf("invalid usage meter %q", meter)
+ }
+}
+
+func (s *MonStore) ensureMonthlyCounter(ctx context.Context, uid int64, monthKey string) error {
+ id := monthlyCounterID(uid, monthKey)
+ var existing domain.MonthlyCounter
+ if err := s.counters.FindOne(ctx, &existing, bson.M{"_id": id}); err == nil {
+ return nil
+ } else if err != mon.ErrNotFound {
+ return err
+ }
+ events, err := s.ListEvents(ctx, uid, monthKey, domain.KeyModePlatform, 0)
+ if err != nil {
+ return err
+ }
+ counter := &domain.MonthlyCounter{
+ ID: id, UID: uid, MonthKey: monthKey, ByMeter: map[string]int{}, UpdatedAt: domain.NowNano(),
+ }
+ for _, event := range events {
+ credits := event.Credits
+ if minimum := domain.MeterCost(event.Meter); credits < minimum {
+ credits = minimum
+ }
+ counter.TotalCredits += credits
+ counter.ByMeter[event.Meter] += credits
+ }
+ if _, err := s.counters.InsertOne(ctx, counter); err != nil && !mongo.IsDuplicateKeyError(err) {
+ return err
+ }
+ return nil
+}
+
+func (s *MonStore) ReservePlatform(ctx context.Context, uid int64, monthKey, meter string, cost, totalLimit, meterLimit int) error {
+ field, err := meterCounterField(meter)
+ if err != nil {
+ return err
+ }
+ if totalLimit >= 0 && cost > totalLimit {
+ return domain.ErrQuotaExceeded
+ }
+ if meterLimit >= 0 && cost > meterLimit {
+ return domain.ErrMeterCapExceeded
+ }
+ if err := s.ensureMonthlyCounter(ctx, uid, monthKey); err != nil {
+ return err
+ }
+ filter := bson.M{"_id": monthlyCounterID(uid, monthKey)}
+ if totalLimit >= 0 {
+ filter["total_credits"] = bson.M{"$lte": totalLimit - cost}
+ }
+ if meterLimit >= 0 {
+ filter["$or"] = bson.A{
+ bson.M{field: bson.M{"$exists": false}},
+ bson.M{field: bson.M{"$lte": meterLimit - cost}},
+ }
+ }
+ res, err := s.counters.UpdateOne(ctx, filter, bson.M{
+ "$inc": bson.M{"total_credits": cost, field: cost},
+ "$set": bson.M{"updated_at": domain.NowNano()},
+ })
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount != 0 {
+ return nil
+ }
+ counter, err := s.GetMonthlyCounter(ctx, uid, monthKey)
+ if err != nil {
+ return err
+ }
+ if totalLimit >= 0 && counter.TotalCredits+cost > totalLimit {
+ return domain.ErrQuotaExceeded
+ }
+ if meterLimit >= 0 && counter.ByMeter[meter]+cost > meterLimit {
+ return domain.ErrMeterCapExceeded
+ }
+ return fmt.Errorf("platform quota reservation did not match counter")
+}
+
+func (s *MonStore) GetMonthlyCounter(ctx context.Context, uid int64, monthKey string) (*domain.MonthlyCounter, error) {
+ var counter domain.MonthlyCounter
+ err := s.counters.FindOne(ctx, &counter, bson.M{"_id": monthlyCounterID(uid, monthKey)})
+ if err == mon.ErrNotFound {
+ return nil, domain.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if counter.ByMeter == nil {
+ counter.ByMeter = map[string]int{}
+ }
+ return &counter, nil
+}
+
func (s *MonStore) InsertEvent(ctx context.Context, e *domain.Event) error {
_, err := s.events.InsertOne(ctx, e)
return err
@@ -62,6 +170,14 @@ func (s *MonStore) ListEventsAll(ctx context.Context, monthKey string, limit int
return list, err
}
+func (s *MonStore) ListEventsRange(ctx context.Context, fromNano, toExclusiveNano int64) ([]*domain.Event, error) {
+ var list []*domain.Event
+ err := s.events.Find(ctx, &list, bson.M{
+ "created_at": bson.M{"$gte": fromNano, "$lt": toExclusiveNano},
+ }, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}))
+ 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})
diff --git a/apps/backend/internal/module/usage/usecase/analytics.go b/apps/backend/internal/module/usage/usecase/analytics.go
new file mode 100644
index 0000000..f2a6471
--- /dev/null
+++ b/apps/backend/internal/module/usage/usecase/analytics.go
@@ -0,0 +1,238 @@
+package usecase
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "sort"
+ "time"
+
+ "apps/backend/internal/module/usage/domain"
+)
+
+const analyticsDateLayout = "2006-01-02"
+
+func ParseAnalyticsQuery(granularity, fromRaw, toRaw string) (domain.AnalyticsQuery, error) {
+ if granularity != "day" && granularity != "month" && granularity != "year" {
+ return domain.AnalyticsQuery{}, fmt.Errorf("%w: granularity must be day, month, or year", domain.ErrInvalidRange)
+ }
+ from, err := time.Parse(analyticsDateLayout, fromRaw)
+ if err != nil {
+ return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must be YYYY-MM-DD", domain.ErrInvalidRange)
+ }
+ to, err := time.Parse(analyticsDateLayout, toRaw)
+ if err != nil {
+ return domain.AnalyticsQuery{}, fmt.Errorf("%w: to must be YYYY-MM-DD", domain.ErrInvalidRange)
+ }
+ if from.After(to) {
+ return domain.AnalyticsQuery{}, fmt.Errorf("%w: from must not be after to", domain.ErrInvalidRange)
+ }
+ q := domain.AnalyticsQuery{Granularity: granularity, From: from.UTC(), To: to.UTC()}
+ if len(analyticsBuckets(q)) > map[string]int{"day": 93, "month": 36, "year": 8}[granularity] {
+ return domain.AnalyticsQuery{}, fmt.Errorf("%w: range exceeds %s limit", domain.ErrInvalidRange, granularity)
+ }
+ return q, nil
+}
+
+type analyticsBucketRange struct {
+ key string
+ label string
+ from time.Time
+ to time.Time
+}
+
+func analyticsBuckets(q domain.AnalyticsQuery) []analyticsBucketRange {
+ var buckets []analyticsBucketRange
+ switch q.Granularity {
+ case "day":
+ for cur := q.From; !cur.After(q.To); cur = cur.AddDate(0, 0, 1) {
+ buckets = append(buckets, analyticsBucketRange{key: cur.Format(analyticsDateLayout), label: cur.Format("01/02"), from: cur, to: cur})
+ }
+ case "month":
+ for cur := time.Date(q.From.Year(), q.From.Month(), 1, 0, 0, 0, 0, time.UTC); !cur.After(q.To); cur = cur.AddDate(0, 1, 0) {
+ end := cur.AddDate(0, 1, -1)
+ buckets = append(buckets, analyticsBucketRange{key: cur.Format("2006-01"), label: cur.Format("2006/01"), from: maxTime(cur, q.From), to: minTime(end, q.To)})
+ }
+ case "year":
+ for year := q.From.Year(); year <= q.To.Year(); year++ {
+ start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
+ end := time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC)
+ key := fmt.Sprintf("%04d", year)
+ buckets = append(buckets, analyticsBucketRange{key: key, label: key, from: maxTime(start, q.From), to: minTime(end, q.To)})
+ }
+ }
+ return buckets
+}
+
+func maxTime(a, b time.Time) time.Time {
+ if a.After(b) {
+ return a
+ }
+ return b
+}
+
+func minTime(a, b time.Time) time.Time {
+ if a.Before(b) {
+ return a
+ }
+ return b
+}
+
+func proratedCredits(monthly int, from, to time.Time) int {
+ credits := 0.0
+ for cur := from; !cur.After(to); cur = cur.AddDate(0, 0, 1) {
+ days := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day()
+ credits += float64(monthly) / float64(days)
+ }
+ return int(math.Round(credits))
+}
+
+func isAIMeter(meter string) bool {
+ return meter == domain.MeterAICopy || meter == domain.MeterAIResearch || meter == domain.MeterAIImage
+}
+
+func emptyMeterCounts() map[string]domain.MeterCount {
+ return map[string]domain.MeterCount{
+ domain.MeterAICopy: {}, domain.MeterAIResearch: {}, domain.MeterWebSearch: {}, domain.MeterAIImage: {},
+ }
+}
+
+func (s *Service) TenantAnalytics(ctx context.Context, q domain.AnalyticsQuery, tenantMembers []domain.AnalyticsMember) (*domain.TenantAnalytics, error) {
+ events, err := s.Repo.ListEventsRange(ctx, q.From.UnixNano(), q.To.AddDate(0, 0, 1).UnixNano())
+ if err != nil {
+ return nil, err
+ }
+
+ type memberAcc struct {
+ row domain.AnalyticsMemberRow
+ }
+ members := make(map[int64]*memberAcc, len(tenantMembers))
+ for _, member := range tenantMembers {
+ members[member.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: member.UID, Email: member.Email, DisplayName: member.DisplayName}}
+ }
+ for _, event := range events {
+ if members[event.UID] == nil {
+ members[event.UID] = &memberAcc{row: domain.AnalyticsMemberRow{UID: event.UID}}
+ }
+ }
+
+ buckets := analyticsBuckets(q)
+ bucketByKey := make(map[string]int, len(buckets))
+ result := &domain.TenantAnalytics{
+ Granularity: q.Granularity, From: q.From.Format(analyticsDateLayout), To: q.To.Format(analyticsDateLayout),
+ RangeLabel: fmt.Sprintf("%s - %s", q.From.Format(analyticsDateLayout), q.To.Format(analyticsDateLayout)),
+ ByMeter: emptyMeterCounts(), Byok: domain.ByokBlock{ByMeter: emptyMeterCounts()},
+ }
+ for _, bucket := range buckets {
+ row := domain.AnalyticsBucket{Key: bucket.key, Label: bucket.label}
+ result.Series = append(result.Series, row)
+ bucketByKey[bucket.key] = len(result.Series) - 1
+ }
+
+ for _, acc := range members {
+ prefs, prefsErr := s.ensurePrefs(ctx, acc.row.UID)
+ if prefsErr != nil {
+ return nil, prefsErr
+ }
+ plan := domain.ResolvePlan(prefs.PlanID)
+ acc.row.PlanID, acc.row.Unlimited = plan.ID, prefs.Unlimited
+ acc.row.PurchasedCredits = proratedCredits(plan.MonthlyCredits, q.From, q.To)
+ result.PurchasedCredits += acc.row.PurchasedCredits
+ for i, bucket := range buckets {
+ result.Series[i].PurchasedCredits += proratedCredits(plan.MonthlyCredits, bucket.from, bucket.to)
+ }
+ }
+
+ for _, event := range events {
+ bucketIndex, hasBucket := bucketByKey[eventBucketKey(event.CreatedAt, q.Granularity)]
+ acc := members[event.UID]
+ if event.KeyMode == domain.KeyModeByok {
+ result.Byok.CallCount++
+ meter := result.Byok.ByMeter[event.Meter]
+ meter.Count++
+ result.Byok.ByMeter[event.Meter] = meter
+ acc.row.ByokCallCount++
+ if hasBucket {
+ result.Series[bucketIndex].ByokCallCount++
+ }
+ continue
+ }
+ if event.KeyMode != domain.KeyModePlatform {
+ continue
+ }
+ credits := event.Credits
+ if credits < 0 {
+ credits = 0
+ }
+ result.ConsumedCredits += credits
+ meter := result.ByMeter[event.Meter]
+ meter.Count++
+ meter.Credits += credits
+ result.ByMeter[event.Meter] = meter
+ acc.row.TotalCredits += credits
+ if isAIMeter(event.Meter) {
+ result.AICalls++
+ acc.row.AICalls++
+ if hasBucket {
+ result.Series[bucketIndex].AICalls++
+ }
+ } else if event.Meter == domain.MeterWebSearch {
+ result.SearchCalls++
+ acc.row.SearchCalls++
+ if hasBucket {
+ result.Series[bucketIndex].SearchCalls++
+ }
+ }
+ if hasBucket {
+ result.Series[bucketIndex].ConsumedCredits += credits
+ }
+ }
+
+ result.RemainingCredits = maxInt(0, result.PurchasedCredits-result.ConsumedCredits)
+ result.Pct = percentage(result.ConsumedCredits, result.PurchasedCredits)
+ for _, acc := range members {
+ acc.row.RemainingCredits = maxInt(0, acc.row.PurchasedCredits-acc.row.TotalCredits)
+ acc.row.Pct = percentage(acc.row.TotalCredits, acc.row.PurchasedCredits)
+ result.Members = append(result.Members, acc.row)
+ }
+ sort.Slice(result.Members, func(i, j int) bool {
+ if result.Members[i].TotalCredits == result.Members[j].TotalCredits {
+ return result.Members[i].UID < result.Members[j].UID
+ }
+ return result.Members[i].TotalCredits > result.Members[j].TotalCredits
+ })
+ return result, nil
+}
+
+func eventBucketKey(nano int64, granularity string) string {
+ t := time.Unix(0, nano).UTC()
+ switch granularity {
+ case "day":
+ return t.Format(analyticsDateLayout)
+ case "month":
+ return t.Format("2006-01")
+ default:
+ return t.Format("2006")
+ }
+}
+
+func percentage(used, total int) int {
+ if total <= 0 {
+ return 0
+ }
+ return minInt(999, int(math.Round(float64(used)*100/float64(total))))
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/apps/backend/internal/module/usage/usecase/analytics_test.go b/apps/backend/internal/module/usage/usecase/analytics_test.go
new file mode 100644
index 0000000..5810440
--- /dev/null
+++ b/apps/backend/internal/module/usage/usecase/analytics_test.go
@@ -0,0 +1,73 @@
+package usecase_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "apps/backend/internal/module/usage/domain"
+ "apps/backend/internal/module/usage/repository"
+ "apps/backend/internal/module/usage/usecase"
+
+ "github.com/stretchr/testify/require"
+)
+
+func analyticsNano(t *testing.T, value string) int64 {
+ t.Helper()
+ parsed, err := time.Parse(time.RFC3339, value)
+ require.NoError(t, err)
+ return parsed.UnixNano()
+}
+
+func TestParseAnalyticsQueryValidationAndCaps(t *testing.T) {
+ _, err := usecase.ParseAnalyticsQuery("week", "2026-01-01", "2026-01-02")
+ require.ErrorIs(t, err, domain.ErrInvalidRange)
+ _, err = usecase.ParseAnalyticsQuery("day", "2026-01-03", "2026-01-02")
+ require.ErrorIs(t, err, domain.ErrInvalidRange)
+ _, err = usecase.ParseAnalyticsQuery("day", "2026-01-01", "2026-04-04")
+ require.ErrorIs(t, err, domain.ErrInvalidRange)
+ _, err = usecase.ParseAnalyticsQuery("month", "2023-01-01", "2025-12-31")
+ require.NoError(t, err)
+}
+
+func TestTenantAnalyticsUsesInclusiveUTCBoundsAndSeparateModes(t *testing.T) {
+ repo := repository.NewMemory()
+ for _, event := range []*domain.Event{
+ {ID: "before", UID: 1, Meter: domain.MeterAICopy, Credits: 9, KeyMode: domain.KeyModePlatform, CreatedAt: analyticsNano(t, "2026-01-31T23:59:59Z")},
+ {ID: "first", UID: 1, Meter: domain.MeterAICopy, Credits: 2, KeyMode: domain.KeyModePlatform, CreatedAt: analyticsNano(t, "2026-02-01T00:00:00Z")},
+ {ID: "byok", UID: 1, Meter: domain.MeterWebSearch, Credits: 99, KeyMode: domain.KeyModeByok, CreatedAt: analyticsNano(t, "2026-02-02T12:00:00Z")},
+ {ID: "last", UID: 1, Meter: domain.MeterWebSearch, Credits: 1, KeyMode: domain.KeyModePlatform, CreatedAt: analyticsNano(t, "2026-02-02T23:59:59.999999999Z")},
+ {ID: "after", UID: 1, Meter: domain.MeterAICopy, Credits: 9, KeyMode: domain.KeyModePlatform, CreatedAt: analyticsNano(t, "2026-02-03T00:00:00Z")},
+ } {
+ require.NoError(t, repo.InsertEvent(context.Background(), event))
+ }
+ query, err := usecase.ParseAnalyticsQuery("day", "2026-02-01", "2026-02-02")
+ require.NoError(t, err)
+ result, err := usecase.New(repo, nil).TenantAnalytics(context.Background(), query, []domain.AnalyticsMember{{UID: 1, Email: "one@example.test", DisplayName: "One"}})
+ require.NoError(t, err)
+
+ require.Equal(t, 3, result.ConsumedCredits)
+ require.Equal(t, 1, result.AICalls)
+ require.Equal(t, 1, result.SearchCalls)
+ require.Equal(t, 1, result.Byok.CallCount)
+ require.Equal(t, 0, result.Byok.ByMeter[domain.MeterWebSearch].Credits)
+ require.Equal(t, 1, result.Byok.ByMeter[domain.MeterWebSearch].Count)
+ require.Len(t, result.Series, 2)
+ require.Equal(t, 2, result.Series[0].ConsumedCredits)
+ require.Equal(t, 1, result.Series[1].ConsumedCredits)
+ require.Equal(t, 1, result.Series[1].ByokCallCount)
+ require.Equal(t, 3, result.Members[0].TotalCredits)
+ require.Equal(t, 1, result.Members[0].ByokCallCount)
+}
+
+func TestMemoryListEventsRangeUsesExclusiveUpperBound(t *testing.T) {
+ repo := repository.NewMemory()
+ for i, nano := range []int64{99, 100, 199, 200} {
+ require.NoError(t, repo.InsertEvent(context.Background(), &domain.Event{ID: string(rune('a' + i)), CreatedAt: nano}))
+ }
+ list, err := repo.ListEventsRange(context.Background(), 100, 200)
+ require.NoError(t, err)
+ require.Len(t, list, 2)
+ require.Equal(t, int64(100), list[0].CreatedAt)
+ require.Equal(t, int64(199), list[1].CreatedAt)
+}
diff --git a/apps/backend/internal/module/usage/usecase/platform_gate.go b/apps/backend/internal/module/usage/usecase/platform_gate.go
index 857b4cb..6860544 100644
--- a/apps/backend/internal/module/usage/usecase/platform_gate.go
+++ b/apps/backend/internal/module/usage/usecase/platform_gate.go
@@ -2,6 +2,7 @@ package usecase
import (
"fmt"
+ "strings"
"sync"
"time"
@@ -103,13 +104,20 @@ func (AlwaysAllowPlatform) MarkLimited(time.Time) {}
// than bypassing a shared safety limit.
type RedisPlatformGate struct {
client *redis.Redis
+ namespace string
aiPerMin int
searchPerMin int
}
-func NewRedisPlatformGate(conf redis.RedisConf, aiPerMin, searchPerMin int) *RedisPlatformGate {
+func NewRedisPlatformGate(conf redis.RedisConf, aiPerMin, searchPerMin int, namespace ...string) *RedisPlatformGate {
+ prefix := "haixun:dev:v1"
+ if len(namespace) > 0 {
+ if configured := strings.Trim(strings.TrimSpace(namespace[0]), ":"); configured != "" {
+ prefix = configured
+ }
+ }
return &RedisPlatformGate{
- client: redis.MustNewRedis(conf), aiPerMin: aiPerMin, searchPerMin: searchPerMin,
+ client: redis.MustNewRedis(conf), namespace: prefix, aiPerMin: aiPerMin, searchPerMin: searchPerMin,
}
}
@@ -117,7 +125,7 @@ func (g *RedisPlatformGate) MarkLimited(until time.Time) {
if g == nil || g.client == nil {
return
}
- key := "haixun:usage:platform:limited"
+ key := g.limitedKey()
if until.IsZero() || !until.After(time.Now()) {
_, _ = g.client.Del(key)
return
@@ -138,7 +146,7 @@ func (g *RedisPlatformGate) AllowPlatform(meter string) bool {
limit, bucket = g.searchPerMin, "search"
}
if limit <= 0 {
- limited, err := g.client.Exists("haixun:usage:platform:limited")
+ limited, err := g.client.Exists(g.limitedKey())
return err == nil && !limited
}
window := time.Now().UTC().Format("200601021504")
@@ -148,7 +156,7 @@ local n = redis.call('INCR', KEYS[2])
if n == 1 then redis.call('EXPIRE', KEYS[2], 120) end
if n > tonumber(ARGV[1]) then return 0 end
return 1
-`, []string{"haixun:usage:platform:limited", fmt.Sprintf("haixun:usage:platform:%s:%s", bucket, window)}, limit)
+`, []string{g.limitedKey(), g.windowKey(bucket, window)}, limit)
if err != nil {
return false
}
@@ -161,3 +169,11 @@ return 1
return false
}
}
+
+func (g *RedisPlatformGate) limitedKey() string {
+ return fmt.Sprintf("%s:usage:platform:{platform-gate}:limited", g.namespace)
+}
+
+func (g *RedisPlatformGate) windowKey(bucket, window string) string {
+ return fmt.Sprintf("%s:usage:platform:{platform-gate}:%s:%s", g.namespace, bucket, window)
+}
diff --git a/apps/backend/internal/module/usage/usecase/platform_gate_test.go b/apps/backend/internal/module/usage/usecase/platform_gate_test.go
new file mode 100644
index 0000000..a071ffc
--- /dev/null
+++ b/apps/backend/internal/module/usage/usecase/platform_gate_test.go
@@ -0,0 +1,15 @@
+package usecase
+
+import "testing"
+
+func TestRedisPlatformGateKeysShareClusterHashTag(t *testing.T) {
+ gate := &RedisPlatformGate{namespace: "haixun:test:v1"}
+ limited := gate.limitedKey()
+ window := gate.windowKey("ai", "202607141200")
+ if limited != "haixun:test:v1:usage:platform:{platform-gate}:limited" {
+ t.Fatalf("unexpected limited key %q", limited)
+ }
+ if window != "haixun:test:v1:usage:platform:{platform-gate}:ai:202607141200" {
+ t.Fatalf("unexpected window key %q", window)
+ }
+}
diff --git a/apps/backend/internal/module/usage/usecase/resolver.go b/apps/backend/internal/module/usage/usecase/resolver.go
index 258e938..a1b30e6 100644
--- a/apps/backend/internal/module/usage/usecase/resolver.go
+++ b/apps/backend/internal/module/usage/usecase/resolver.go
@@ -37,7 +37,10 @@ func (r *SettingsResolver) platformAIKey(provider string) string {
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 {
+ if err != nil {
+ return "", false, err
+ }
+ if st == nil {
st = memberDomain.DefaultSettings(uid)
}
provider := ai.NormalizeProvider(st.Provider)
@@ -72,7 +75,10 @@ func (r *SettingsResolver) ResolveKey(ctx context.Context, uid int64, meter stri
if !has {
return "", "", domain.ErrNoKey
}
- st, _ := r.Members.GetSettings(ctx, uid)
+ st, err := r.Members.GetSettings(ctx, uid)
+ if err != nil {
+ return "", "", err
+ }
if st == nil {
st = memberDomain.DefaultSettings(uid)
}
diff --git a/apps/backend/internal/module/usage/usecase/service.go b/apps/backend/internal/module/usage/usecase/service.go
index b3fdb8f..c41600f 100644
--- a/apps/backend/internal/module/usage/usecase/service.go
+++ b/apps/backend/internal/module/usage/usecase/service.go
@@ -46,7 +46,8 @@ func New(repo domain.Repository, resolver KeyResolver) *Service {
return &Service{Repo: repo, Resolver: resolver}
}
-// RecordCall after successful external call (or for meter tests).
+// RecordCall preserves the audit event after a call. Platform credits were already
+// reserved by PrepareCall; this method must not charge them a second time.
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")
@@ -54,9 +55,6 @@ func (s *Service) RecordCall(ctx context.Context, uid int64, meter, keyMode, lab
credits := 0
if keyMode == domain.KeyModePlatform {
credits = domain.MeterCost(meter)
- if err := s.checkPlatformQuota(ctx, uid, meter, credits); err != nil {
- return nil, err
- }
}
now := domain.NowNano()
e := &domain.Event{
@@ -87,8 +85,7 @@ func (s *Service) PrepareCall(ctx context.Context, uid int64, meter string) (key
if s.Gate != nil && !s.Gate.AllowPlatform(meter) {
return "", domain.ErrPlatformCapacity
}
- cost := domain.MeterCost(meter)
- if err := s.checkPlatformQuota(ctx, uid, meter, cost); err != nil {
+ if err := s.reservePlatform(ctx, uid, meter); err != nil {
return "", err
}
}
@@ -104,55 +101,41 @@ func (s *Service) MarkPlatformLimited(until time.Time) {
s.Gate.MarkLimited(until)
}
-// checkPlatformQuota — 總池 + 分項點數上限;unlimited 略過。
-func (s *Service) checkPlatformQuota(ctx context.Context, uid int64, meter string, cost int) error {
- if cost < 0 {
- cost = 0
- }
- prefs, _ := s.ensurePrefs(ctx, uid)
- if prefs.Unlimited {
- return nil
- }
- sum, err := s.GetSummary(ctx, uid, domain.CurrentMonthKey())
+func (s *Service) reservePlatform(ctx context.Context, uid int64, meter string) error {
+ prefs, err := s.ensurePrefs(ctx, uid)
if err != nil {
return err
}
- // 1) 整月 platform 總點
- if sum.Platform.CreditsRemaining < cost {
- return domain.ErrQuotaExceeded
- }
- // 2) 分項點數硬上限(platform only;加總 = MonthlyCredits)
plan := domain.ResolvePlan(prefs.PlanID)
+ totalLimit, meterLimit := plan.MonthlyCredits, -1
if capN, ok := plan.SoftCaps[meter]; ok && capN > 0 {
- usedCredits := 0
- usedCount := 0
- if m, ok := sum.Platform.ByMeter[meter]; ok {
- usedCredits = m.Credits
- usedCount = m.Count
- // 舊資料若 credits 漏記,用次數 × 單價估
- if usedCredits < usedCount*domain.MeterCost(meter) {
- usedCredits = usedCount * domain.MeterCost(meter)
- }
- }
- if usedCredits+cost > capN {
- return domain.ErrMeterCapExceeded
- }
+ meterLimit = capN
}
- return nil
+ if prefs.Unlimited {
+ totalLimit, meterLimit = -1, -1
+ }
+ return s.Repo.ReservePlatform(ctx, uid, domain.CurrentMonthKey(), meter, domain.MeterCost(meter), totalLimit, meterLimit)
}
func (s *Service) ensurePrefs(ctx context.Context, uid int64) (*domain.MemberPrefs, error) {
p, err := s.Repo.GetPrefs(ctx, uid)
- if err != nil || p == nil {
+ if err != nil {
+ return nil, err
+ }
+ if p == nil {
p = &domain.MemberPrefs{UID: uid, PlanID: domain.PlanFree, UpdatedAt: domain.NowNano()}
- _ = s.Repo.SavePrefs(ctx, p)
+ if err := s.Repo.SavePrefs(ctx, p); err != nil {
+ return nil, err
+ }
}
// 正規化 plan_id(小寫/有效方案)
norm := domain.ResolvePlan(p.PlanID).ID
if p.PlanID != norm {
p.PlanID = norm
p.UpdatedAt = domain.NowNano()
- _ = s.Repo.SavePrefs(ctx, p)
+ if err := s.Repo.SavePrefs(ctx, p); err != nil {
+ return nil, err
+ }
}
return p, nil
}
@@ -161,7 +144,10 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
if monthKey == "" {
monthKey = domain.CurrentMonthKey()
}
- prefs, _ := s.ensurePrefs(ctx, uid)
+ prefs, err := s.ensurePrefs(ctx, uid)
+ if err != nil {
+ return nil, err
+ }
plan := domain.ResolvePlan(prefs.PlanID)
// 回寫正規化 plan_id,避免 DB 殘留大小寫/空白導致永遠 fallback Free
if prefs.PlanID != plan.ID {
@@ -191,6 +177,16 @@ func (s *Service) GetSummary(ctx context.Context, uid int64, monthKey string) (*
byokBy[e.Meter] = m
}
}
+ if counter, counterErr := s.Repo.GetMonthlyCounter(ctx, uid, monthKey); counterErr == nil {
+ platUsed = counter.TotalCredits
+ for meter, credits := range counter.ByMeter {
+ m := platBy[meter]
+ m.Credits = credits
+ platBy[meter] = m
+ }
+ } else if counterErr != domain.ErrNotFound {
+ return nil, counterErr
+ }
remaining := plan.MonthlyCredits - platUsed
if remaining < 0 {
remaining = 0
@@ -227,7 +223,10 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64,
// members cannot set unlimited
return nil, domain.ErrForbidden
}
- p, _ := s.ensurePrefs(ctx, targetUID)
+ p, err := s.ensurePrefs(ctx, targetUID)
+ if err != nil {
+ return nil, err
+ }
if planID != "" {
id := strings.ToLower(strings.TrimSpace(planID))
if _, ok := domain.Plans[id]; !ok {
@@ -238,6 +237,7 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64,
return nil, domain.ErrForbidden
}
p.PlanID = id
+ p.AdminOverride = true
}
if unlimited != nil && isAdmin {
p.Unlimited = *unlimited
@@ -250,24 +250,7 @@ func (s *Service) SetPrefs(ctx context.Context, actorUID int64, targetUID int64,
}
func (s *Service) PurchasePlan(ctx context.Context, uid int64, planID, mockRef string) (*domain.Purchase, error) {
- id := strings.ToLower(strings.TrimSpace(planID))
- plan, ok := domain.Plans[id]
- if !ok || plan.ID == "" {
- return nil, domain.ErrInvalidPlan
- }
- p, _ := s.ensurePrefs(ctx, uid)
- p.PlanID = plan.ID
- p.UpdatedAt = domain.NowNano()
- if err := s.Repo.SavePrefs(ctx, p); err != nil {
- return nil, err
- }
- pur := &domain.Purchase{
- ID: uuid.NewString(), UID: uid, PlanID: plan.ID, MockRef: mockRef, CreatedAt: domain.NowNano(),
- }
- if err := s.Repo.InsertPurchase(ctx, pur); err != nil {
- return nil, err
- }
- return pur, nil
+ return nil, domain.ErrPurchaseDisabled
}
func (s *Service) ListMyPurchases(ctx context.Context, uid int64, limit int) ([]*domain.Purchase, error) {
@@ -307,7 +290,10 @@ func (s *Service) TenantSummary(ctx context.Context, monthKey string) (*domain.T
}
var members []domain.TenantRow
for uid, a := range byUID {
- prefs, _ := s.ensurePrefs(ctx, uid)
+ prefs, err := s.ensurePrefs(ctx, uid)
+ if err != nil {
+ return nil, err
+ }
members = append(members, domain.TenantRow{
UID: uid, PlanID: prefs.PlanID, Unlimited: prefs.Unlimited,
PlatformCreditsUsed: a.plat, ByokCallCount: a.byok,
diff --git a/apps/backend/internal/module/usage/usecase/service_test.go b/apps/backend/internal/module/usage/usecase/service_test.go
index 27a3291..f8832f1 100644
--- a/apps/backend/internal/module/usage/usecase/service_test.go
+++ b/apps/backend/internal/module/usage/usecase/service_test.go
@@ -3,6 +3,7 @@ package usecase_test
import (
"context"
"fmt"
+ "sync"
"testing"
"time"
@@ -145,7 +146,9 @@ func TestST_13_PlatformQuota(t *testing.T) {
svc := newUsage(1_000_001, domain.KeyModePlatform)
// free: ai_copy soft cap 60pt;先燒滿分項
for i := 0; i < 60; i++ {
- _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, domain.KeyModePlatform, "x", "t")
+ mode, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
+ require.NoError(t, err)
+ _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAICopy, mode, "x", "t")
require.NoError(t, err)
}
_, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAICopy)
@@ -153,24 +156,78 @@ func TestST_13_PlatformQuota(t *testing.T) {
// 分項鎖死後,其他 meter 在總池仍有餘額時可繼續
_, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch)
require.NoError(t, err)
+ _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t")
+ require.NoError(t, err)
// 燒光剩餘總池:search 30 + research 15 + image 15 = 60
- for i := 0; i < 30; i++ {
- _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, domain.KeyModePlatform, "s", "t")
+ for i := 1; i < 30; i++ {
+ mode, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch)
+ require.NoError(t, err)
+ _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterWebSearch, mode, "s", "t")
require.NoError(t, err)
}
for i := 0; i < 5; i++ { // 5*3=15
- _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIResearch, domain.KeyModePlatform, "r", "t")
+ mode, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAIResearch)
+ require.NoError(t, err)
+ _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIResearch, mode, "r", "t")
require.NoError(t, err)
}
// image 15pt = 3 張
for i := 0; i < 3; i++ {
- _, err := svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, domain.KeyModePlatform, "img", "t")
+ mode, err := svc.PrepareCall(context.Background(), 1_000_001, domain.MeterAIImage)
+ require.NoError(t, err)
+ _, err = svc.RecordCall(context.Background(), 1_000_001, domain.MeterAIImage, mode, "img", "t")
require.NoError(t, err)
}
_, err = svc.PrepareCall(context.Background(), 1_000_001, domain.MeterWebSearch)
require.ErrorIs(t, err, domain.ErrQuotaExceeded)
}
+func TestPlatformReservation_ConcurrentOneCreditCannotBeOverspent(t *testing.T) {
+ repo := repository.NewMemory()
+ const attempts = 32
+ start := make(chan struct{})
+ results := make(chan error, attempts)
+ var wg sync.WaitGroup
+ for i := 0; i < attempts; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ results <- repo.ReservePlatform(context.Background(), 1_000_003, domain.CurrentMonthKey(), domain.MeterAICopy, 1, 1, 1)
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(results)
+
+ succeeded := 0
+ for err := range results {
+ if err == nil {
+ succeeded++
+ continue
+ }
+ require.ErrorIs(t, err, domain.ErrQuotaExceeded)
+ }
+ require.Equal(t, 1, succeeded)
+ counter, err := repo.GetMonthlyCounter(context.Background(), 1_000_003, domain.CurrentMonthKey())
+ require.NoError(t, err)
+ require.Equal(t, 1, counter.TotalCredits)
+ require.Equal(t, 1, counter.ByMeter[domain.MeterAICopy])
+}
+
+func TestPrepareCall_BYOKDoesNotCreatePlatformCounter(t *testing.T) {
+ repo := repository.NewMemory()
+ uid := int64(1_000_004)
+ svc := usecase.New(repo, &usecase.StaticResolver{Map: map[string]string{
+ fmt.Sprintf("%d:%s", uid, domain.MeterAICopy): domain.KeyModeByok,
+ }})
+ mode, err := svc.PrepareCall(context.Background(), uid, domain.MeterAICopy)
+ require.NoError(t, err)
+ require.Equal(t, domain.KeyModeByok, mode)
+ _, err = repo.GetMonthlyCounter(context.Background(), uid, domain.CurrentMonthKey())
+ require.ErrorIs(t, err, domain.ErrNotFound)
+}
+
func TestUSG_MeterCap_Image(t *testing.T) {
svc := newUsage(1_000_001, domain.KeyModePlatform)
// free ai_image cap 15pt = 3 次;第 4 次因分項上限擋
@@ -210,32 +267,16 @@ func TestUSG_PlatformCapacity_RPM(t *testing.T) {
require.ErrorIs(t, err, domain.ErrPlatformCapacity)
}
-func TestUSG_11_PurchasePlan(t *testing.T) {
+func TestUSG_11_LegacyPurchaseCannotActivatePaidPlan(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)
+ require.Nil(t, p)
+ require.ErrorIs(t, err, domain.ErrPurchaseDisabled)
prefs, _ := svc.GetPrefs(context.Background(), 1_000_001)
- require.Equal(t, domain.PlanStarter, prefs.PlanID)
+ require.Equal(t, domain.PlanFree, prefs.PlanID)
list, err := svc.ListMyPurchases(context.Background(), 1_000_001, 10)
require.NoError(t, err)
- require.NotEmpty(t, list)
-
- // 購買後 summary 配給必須是 Starter 600,不是 Free
- sum, err := svc.GetSummary(context.Background(), 1_000_001, "")
- require.NoError(t, err)
- require.Equal(t, domain.PlanStarter, sum.PlanID)
- require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsTotal)
- require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.TotalCredits)
- require.Equal(t, domain.Plans[domain.PlanStarter].MonthlyCredits, sum.Platform.CreditsRemaining)
-
- // 大小寫/空白 plan_id 也要生效
- _, err = svc.PurchasePlan(context.Background(), 1_000_001, " Pro ", "mock-pay-2")
- require.NoError(t, err)
- sum, err = svc.GetSummary(context.Background(), 1_000_001, "")
- require.NoError(t, err)
- require.Equal(t, domain.PlanPro, sum.PlanID)
- require.Equal(t, 2000, sum.Platform.CreditsTotal)
+ require.Empty(t, list)
}
func TestUSG_10_TenantAnalyticsSplit(t *testing.T) {
diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go
index f0bb739..b6b73bb 100644
--- a/apps/backend/internal/response/response.go
+++ b/apps/backend/internal/response/response.go
@@ -8,9 +8,10 @@ import (
"apps/backend/internal/domain"
appnotifDomain "apps/backend/internal/module/appnotif/domain"
+ billingDomain "apps/backend/internal/module/billing"
+ inspireDomain "apps/backend/internal/module/inspire/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"
@@ -91,6 +92,18 @@ func mapError(err error) (int, Envelope) {
return be.http, Envelope{Code: be.code, Message: be.msg}
}
switch {
+ case errors.Is(err, billingDomain.ErrDisabled):
+ return http.StatusServiceUnavailable, Envelope{Code: 503010, Message: err.Error()}
+ case errors.Is(err, billingDomain.ErrInvalidPlan), errors.Is(err, billingDomain.ErrInvalidRequest):
+ return http.StatusBadRequest, Envelope{Code: 400080, Message: err.Error()}
+ case errors.Is(err, billingDomain.ErrInvalidSignature):
+ return http.StatusBadRequest, Envelope{Code: 400081, Message: "invalid webhook signature"}
+ case errors.Is(err, billingDomain.ErrNotFound):
+ return http.StatusNotFound, Envelope{Code: 404080, Message: err.Error()}
+ case errors.Is(err, billingDomain.ErrConflict):
+ return http.StatusConflict, Envelope{Code: 409080, Message: err.Error()}
+ case errors.Is(err, billingDomain.ErrCustomerRequired):
+ return http.StatusConflict, Envelope{Code: 409081, Message: err.Error()}
case errors.Is(err, memberDomain.ErrBadPassword):
return http.StatusUnauthorized, Envelope{Code: 401010, Message: "invalid email or password"}
case errors.Is(err, memberDomain.ErrSuspended):
@@ -103,6 +116,8 @@ func mapError(err error) (int, Envelope) {
case errors.Is(err, memberDomain.ErrInvalidCode):
return http.StatusBadRequest, Envelope{Code: 400004, Message: "invalid or expired code"}
+ case errors.Is(err, memberDomain.ErrCodeRateLimited):
+ return http.StatusTooManyRequests, Envelope{Code: 429001, Message: "verification code requested too frequently"}
case errors.Is(err, memberDomain.ErrEmailNotRegistered):
return http.StatusNotFound, Envelope{Code: 404002, Message: "email not registered"}
case errors.Is(err, memberDomain.ErrNotFound):
@@ -152,6 +167,8 @@ func mapError(err error) (int, Envelope) {
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, usageDomain.ErrPurchaseDisabled):
+ return http.StatusGone, Envelope{Code: 410003, Message: err.Error()}
case errors.Is(err, studioDomain.ErrNotFound):
return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"}
case errors.Is(err, studioDomain.ErrForbidden):
@@ -177,7 +194,7 @@ func mapError(err error) (int, Envelope) {
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()}
+ return http.StatusInternalServerError, Envelope{Code: 500000, Message: "internal server error"}
}
}
diff --git a/apps/backend/internal/response/response_test.go b/apps/backend/internal/response/response_test.go
index 93197d5..4db79ff 100644
--- a/apps/backend/internal/response/response_test.go
+++ b/apps/backend/internal/response/response_test.go
@@ -3,6 +3,7 @@ package response
import (
"context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"testing"
@@ -48,3 +49,13 @@ func TestWrite_Error(t *testing.T) {
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env))
require.Equal(t, int64(400010), env.Code)
}
+
+func TestWrite_InternalErrorDoesNotLeakDetails(t *testing.T) {
+ rr := httptest.NewRecorder()
+ Write(context.Background(), rr, nil, errors.New("mongodb password=secret"))
+ require.Equal(t, http.StatusInternalServerError, rr.Code)
+ var env Envelope
+ require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env))
+ require.Equal(t, "internal server error", env.Message)
+ require.NotContains(t, rr.Body.String(), "secret")
+}
diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go
index 46c6a0d..2801975 100644
--- a/apps/backend/internal/svc/service_context.go
+++ b/apps/backend/internal/svc/service_context.go
@@ -2,6 +2,7 @@ package svc
import (
"context"
+ "errors"
"fmt"
"strings"
@@ -13,6 +14,7 @@ import (
"apps/backend/internal/module/ai"
appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase"
+ billingModule "apps/backend/internal/module/billing"
fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store"
@@ -42,14 +44,21 @@ import (
usageUC "apps/backend/internal/module/usage/usecase"
"github.com/zeromicro/go-zero/core/logx"
+ "github.com/zeromicro/go-zero/core/stores/redis"
"github.com/zeromicro/go-zero/rest"
)
+type RedisHealth interface {
+ PingCtx(ctx context.Context) bool
+}
+
// ServiceContext wires goctl middleware + modules.
type ServiceContext struct {
Config config.Config
+ Redis RedisHealth
AuthJWT rest.Middleware
AdminAuth rest.Middleware
+ StripeRawBody rest.Middleware
Members memberDomain.Repository
Auth *memberUC.AuthService
Token *tokenUC.JWTIssuer
@@ -59,6 +68,7 @@ type ServiceContext struct {
Jobs *jobUC.Service
AppNotif *appnotifUC.Service
Usage *usageUC.Service
+ Billing *billingModule.Service
KeyResolver *usageUC.SettingsResolver
AI ai.Client
AIRegistry *ai.Registry
@@ -75,7 +85,12 @@ func NewServiceContext(c config.Config) *ServiceContext {
logx.Must(errString("CacheRedis is required for monc entity cache"))
}
- repo := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis)
+ settingsCodec, err := c.NewMemberSettingsCodec()
+ if err != nil {
+ logx.Must(err)
+ }
+ repo := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis, c.CacheRedis[0].RedisConf, c.Redis.Namespace, settingsCodec)
+ redisClient := redis.MustNewRedis(c.CacheRedis[0].RedisConf)
logx.Infof("mongo monc ready db=%s redis_cache=%s", c.Mongo.Database, c.CacheRedis[0].Host)
issuer := tokenUC.NewJWTIssuer(
@@ -112,7 +127,19 @@ func NewServiceContext(c config.Config) *ServiceContext {
PlatformExa: c.Platform.ExaKey,
}
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
- usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60)
+ usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60, c.Redis.Namespace)
+ billingRepo := billingModule.NewMongoRepository(c.Mongo.URI, c.Mongo.Database)
+ billingSvc := &billingModule.Service{
+ Repo: billingRepo, Enabled: c.Stripe.Enabled, WebhookSecret: c.Stripe.WebhookSecret,
+ StarterPriceID: c.Stripe.StarterPriceID, ProPriceID: c.Stripe.ProPriceID,
+ SuccessURL: c.Stripe.SuccessURL, CancelURL: c.Stripe.CancelURL, PortalReturnURL: c.Stripe.PortalReturnURL,
+ }
+ if c.Stripe.Enabled && strings.TrimSpace(c.Stripe.SecretKey) != "" {
+ billingSvc.Provider = billingModule.NewStripeProvider(c.Stripe.SecretKey)
+ logx.Info("billing: Stripe subscriptions enabled")
+ } else {
+ logx.Info("billing: Stripe disabled")
+ }
// M4 studio: mon store + publish transport(有 Meta 憑證則真發文/同步)
var pub studioPublish.Transport
@@ -125,7 +152,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
logx.Info("studio: Threads publishing disabled until credentials are configured")
}
- aiRegistry := ai.NewRegistry()
+ modelsCache := ai.NewRedisModelsCache(c.CacheRedis[0].RedisConf, c.Redis.Namespace)
+ aiRegistry := ai.NewRegistry(modelsCache, c.Redis.AIModelCacheFingerprintSecret)
aiClient, _ := aiRegistry.Client(ai.ProviderXAI)
searchClient := search.NewExa()
studioSvc := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
@@ -169,6 +197,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
+ Redis: redisClient,
Members: repo,
Auth: auth,
Token: issuer,
@@ -178,6 +207,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
Jobs: jobs,
AppNotif: appN,
Usage: usageSvc,
+ Billing: billingSvc,
KeyResolver: keyRes,
AI: aiClient,
AIRegistry: aiRegistry,
@@ -189,6 +219,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
ExtensionZipPath: zipPath,
AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle,
AdminAuth: middleware.NewAdminAuthMiddleware().Handle,
+ StripeRawBody: middleware.NewStripeRawBodyMiddleware().Handle,
}
}
@@ -212,7 +243,10 @@ func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (boo
return false, nil
}
st, err := d.Members.GetSettings(ctx, uid)
- if err != nil || st == nil {
+ if err != nil {
+ return false, err
+ }
+ if st == nil {
return false, nil
}
return st.DevModeEnabled, nil
@@ -466,7 +500,10 @@ type studioAIKeys struct {
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 {
+ if err != nil {
+ return "", "", "", err
+ }
+ if st == nil {
st = memberDomain.DefaultSettings(ownerUID)
}
// 完全依會員 AI 設定,不寫死/不替換模型 id
@@ -477,6 +514,9 @@ func (k *studioAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider,
}
if k.Resolver != nil {
_, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy)
+ if rerr != nil && !errors.Is(rerr, usageDomain.ErrNoKey) {
+ return provider, model, "", rerr
+ }
if rerr == nil && strings.TrimSpace(key) != "" {
return provider, model, key, nil
}
diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go
index 77ec254..474de20 100644
--- a/apps/backend/internal/types/types.go
+++ b/apps/backend/internal/types/types.go
@@ -216,6 +216,42 @@ type AuthVerifyEmailReq struct {
Code string `json:"code"`
}
+type BillingCheckoutCreateReq struct {
+ PlanId string `json:"plan_id"`
+ RequestId string `json:"request_id"`
+}
+
+type BillingCheckoutData struct {
+ Id string `json:"id"`
+ SessionId string `json:"session_id"`
+ Url string `json:"url"`
+ ExpiresAt int64 `json:"expires_at"`
+ Status string `json:"status,optional"`
+ PaymentStatus string `json:"payment_status,optional"`
+ FulfillmentStatus string `json:"fulfillment_status,optional"`
+ PlanId string `json:"plan_id,optional"`
+}
+
+type BillingCheckoutGetReq struct {
+ Id string `path:"id"`
+}
+
+type BillingPortalData struct {
+ Url string `json:"url"`
+}
+
+type BillingSubscriptionData struct {
+ PlanId string `json:"plan_id"`
+ PaidPlanId string `json:"paid_plan_id,optional"`
+ Status string `json:"status"`
+ CustomerId string `json:"customer_id,optional"`
+ SubscriptionId string `json:"subscription_id,optional"`
+ CurrentPeriodStart int64 `json:"current_period_start,optional"`
+ CurrentPeriodEnd int64 `json:"current_period_end,optional"`
+ CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
+ AdminOverride bool `json:"admin_override"`
+}
+
type BrandActiveData struct {
Id string `json:"id"`
}
@@ -1330,6 +1366,54 @@ type UsageSummaryReq struct {
MonthKey string `form:"month_key,optional"`
}
+type UsageTenantAnalyticsBucket struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ PurchasedCredits int `json:"purchased_credits"`
+ ConsumedCredits int `json:"consumed_credits"`
+ AiCalls int `json:"ai_calls"`
+ SearchCalls int `json:"search_calls"`
+ ByokCallCount int `json:"byok_call_count"`
+}
+
+type UsageTenantAnalyticsData struct {
+ Granularity string `json:"granularity"`
+ From string `json:"from"`
+ To string `json:"to"`
+ RangeLabel string `json:"range_label"`
+ PurchasedCredits int `json:"purchased_credits"`
+ ConsumedCredits int `json:"consumed_credits"`
+ RemainingCredits int `json:"remaining_credits"`
+ Pct int `json:"pct"`
+ AiCalls int `json:"ai_calls"`
+ SearchCalls int `json:"search_calls"`
+ ByMeter map[string]UsageMeterCount `json:"by_meter"`
+ Byok UsageByokBlock `json:"byok"`
+ Series []UsageTenantAnalyticsBucket `json:"series"`
+ Members []UsageTenantAnalyticsMember `json:"members"`
+}
+
+type UsageTenantAnalyticsMember struct {
+ Uid int64 `json:"uid"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Unlimited bool `json:"unlimited"`
+ PlanId string `json:"plan_id"`
+ PurchasedCredits int `json:"purchased_credits"`
+ TotalCredits int `json:"total_credits"`
+ AiCalls int `json:"ai_calls"`
+ SearchCalls int `json:"search_calls"`
+ ByokCallCount int `json:"byok_call_count"`
+ RemainingCredits int `json:"remaining_credits"`
+ Pct int `json:"pct"`
+}
+
+type UsageTenantAnalyticsReq struct {
+ Granularity string `form:"granularity"` // day|month|year
+ From string `form:"from"` // inclusive YYYY-MM-DD UTC
+ To string `form:"to"` // inclusive YYYY-MM-DD UTC
+}
+
type UsageTenantRow struct {
Uid int64 `json:"uid"`
PlanId string `json:"plan_id"`
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index c49f602..7c857e9 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -14,13 +14,190 @@
"taipei-sans-tc": "^0.1.1"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
+ "jsdom": "^26.1.0",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
- "vite": "^8.1.1"
+ "vite": "^8.1.1",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@emnapi/core": {
@@ -57,6 +234,455 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -673,6 +1299,432 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -684,6 +1736,39 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -740,6 +1825,186 @@
}
}
},
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -753,6 +2018,27 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -760,6 +2046,65 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -770,6 +2115,96 @@
"node": ">=8"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -803,6 +2238,125 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "26.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.2.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.5.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.16",
+ "parse5": "^7.2.1",
+ "rrweb-cssom": "^0.8.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^5.1.1",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.1.1",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1064,6 +2618,58 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1083,6 +2689,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/nwsapi": {
+ "version": "2.2.24",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
+ "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/oxlint": {
"version": "1.73.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz",
@@ -1132,6 +2745,36 @@
}
}
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1181,6 +2824,32 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1202,6 +2871,14 @@
"react": "^19.2.7"
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
@@ -1240,6 +2917,20 @@
"react-dom": ">=18"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -1274,6 +2965,78 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1286,6 +3049,13 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1296,12 +3066,80 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/taipei-sans-tc": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz",
"integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1319,6 +3157,82 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^6.1.32"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1425,6 +3339,396 @@
"optional": true
}
}
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite-node/node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
}
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index a4c880e..459af3e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,6 +7,8 @@
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "oxlint",
+ "test": "vitest run",
+ "test:unit": "vitest run",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
@@ -19,9 +21,13 @@
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^6.0.3",
+ "jsdom": "^26.1.0",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
- "vite": "^8.1.1"
+ "vite": "^8.1.1",
+ "vitest": "^3.2.4"
}
}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 07dde1f..79f1831 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,3 +1,4 @@
+import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { RequireAdmin } from "./auth/RequireAdmin";
@@ -6,28 +7,28 @@ import { AppShell } from "./components/layout/AppShell";
import { DataProvider } from "./data/DataContext";
import { I18nProvider } from "./i18n/I18nContext";
import { ThemeProvider } from "./theme/ThemeContext";
-import { AdminUsersPage } from "./pages/AdminUsersPage";
-import { BrandsPage } from "./pages/BrandsPage";
-import { CrewPage } from "./pages/CrewPage";
-import { JobDetailPage } from "./pages/JobDetailPage";
-import { InsightsPage } from "./pages/InsightsPage";
-import { InvitePage } from "./pages/InvitePage";
-import { JobsPage } from "./pages/JobsPage";
-import { ForgotPasswordPage } from "./pages/ForgotPasswordPage";
-import { LoginPage } from "./pages/LoginPage";
-import { OutboxDetailPage } from "./pages/OutboxDetailPage";
-import { OutboxPage } from "./pages/OutboxPage";
-import { ProfilePage } from "./pages/ProfilePage";
-import { ResetPasswordPage } from "./pages/ResetPasswordPage";
-import { ScoutPage } from "./pages/ScoutPage";
-import { SettingsPage } from "./pages/SettingsPage";
-import { PlanCheckoutPage } from "./pages/PlanCheckoutPage";
-import { PlansPage } from "./pages/PlansPage";
-import { UsagePage } from "./pages/UsagePage";
-import { StudioPage } from "./pages/StudioPage";
-import { VerifyEmailPage } from "./pages/VerifyEmailPage";
-import { WizardPage } from "./pages/studio/WizardPage";
-import { TodayPage } from "./pages/TodayPage";
+const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage").then((m) => ({ default: m.AdminUsersPage })));
+const BrandsPage = lazy(() => import("./pages/BrandsPage").then((m) => ({ default: m.BrandsPage })));
+const CrewPage = lazy(() => import("./pages/CrewPage").then((m) => ({ default: m.CrewPage })));
+const JobDetailPage = lazy(() => import("./pages/JobDetailPage").then((m) => ({ default: m.JobDetailPage })));
+const InsightsPage = lazy(() => import("./pages/InsightsPage").then((m) => ({ default: m.InsightsPage })));
+const InvitePage = lazy(() => import("./pages/InvitePage").then((m) => ({ default: m.InvitePage })));
+const JobsPage = lazy(() => import("./pages/JobsPage").then((m) => ({ default: m.JobsPage })));
+const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage").then((m) => ({ default: m.ForgotPasswordPage })));
+const LoginPage = lazy(() => import("./pages/LoginPage").then((m) => ({ default: m.LoginPage })));
+const OutboxDetailPage = lazy(() => import("./pages/OutboxDetailPage").then((m) => ({ default: m.OutboxDetailPage })));
+const OutboxPage = lazy(() => import("./pages/OutboxPage").then((m) => ({ default: m.OutboxPage })));
+const ProfilePage = lazy(() => import("./pages/ProfilePage").then((m) => ({ default: m.ProfilePage })));
+const ResetPasswordPage = lazy(() => import("./pages/ResetPasswordPage").then((m) => ({ default: m.ResetPasswordPage })));
+const ScoutPage = lazy(() => import("./pages/ScoutPage").then((m) => ({ default: m.ScoutPage })));
+const SettingsPage = lazy(() => import("./pages/SettingsPage").then((m) => ({ default: m.SettingsPage })));
+const PlanCheckoutPage = lazy(() => import("./pages/PlanCheckoutPage").then((m) => ({ default: m.PlanCheckoutPage })));
+const PlansPage = lazy(() => import("./pages/PlansPage").then((m) => ({ default: m.PlansPage })));
+const UsagePage = lazy(() => import("./pages/UsagePage").then((m) => ({ default: m.UsagePage })));
+const StudioPage = lazy(() => import("./pages/StudioPage").then((m) => ({ default: m.StudioPage })));
+const VerifyEmailPage = lazy(() => import("./pages/VerifyEmailPage").then((m) => ({ default: m.VerifyEmailPage })));
+const WizardPage = lazy(() => import("./pages/studio/WizardPage").then((m) => ({ default: m.WizardPage })));
+const TodayPage = lazy(() => import("./pages/TodayPage").then((m) => ({ default: m.TodayPage })));
export default function App() {
return (
@@ -36,6 +37,7 @@ export default function App() {
+ }>
} />
} />
@@ -80,6 +82,7 @@ export default function App() {
} />
} />
+
diff --git a/apps/web/src/auth/RequireAuth.tsx b/apps/web/src/auth/RequireAuth.tsx
index 0bc6851..96defa6 100644
--- a/apps/web/src/auth/RequireAuth.tsx
+++ b/apps/web/src/auth/RequireAuth.tsx
@@ -16,7 +16,7 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
}
if (!member) {
- return ;
+ return ;
}
// 未驗證信箱:擋下所有 /app,導向驗證頁
diff --git a/apps/web/src/components/studio/ReplyComposer.tsx b/apps/web/src/components/studio/ReplyComposer.tsx
index ef4172a..143db6f 100644
--- a/apps/web/src/components/studio/ReplyComposer.tsx
+++ b/apps/web/src/components/studio/ReplyComposer.tsx
@@ -23,6 +23,9 @@ type Props = {
sending?: boolean;
/** 主貼回覆 / 回留言 */
label?: string;
+ showAccount?: boolean;
+ actionLabel?: string;
+ actionBusyLabel?: string;
/** 附圖(選填) */
images?: AttachedImage[];
onImagesChange?: (next: AttachedImage[]) => void;
@@ -40,6 +43,9 @@ export function ReplyComposer({
generating,
sending,
label,
+ showAccount = true,
+ actionLabel,
+ actionBusyLabel,
images,
onImagesChange,
}: Props) {
@@ -53,17 +59,19 @@ export function ReplyComposer({
return (
-
+ {showAccount ? (
+
+ ) : null}
diff --git a/apps/web/src/components/usage/PlanPricingGrid.tsx b/apps/web/src/components/usage/PlanPricingGrid.tsx
index c7070c1..03ed06d 100644
--- a/apps/web/src/components/usage/PlanPricingGrid.tsx
+++ b/apps/web/src/components/usage/PlanPricingGrid.tsx
@@ -8,6 +8,7 @@ type Props = {
currentId: PlanId;
formatPrice: (twd: number) => string;
onChoose: (id: PlanId) => void;
+ busy?: boolean;
/** 強調哪一案(預設 starter) */
highlighted?: PlanId;
};
@@ -19,6 +20,7 @@ export function PlanPricingGrid({
currentId,
formatPrice,
onChoose,
+ busy = false,
highlighted = "starter",
}: Props) {
const { t } = useI18n();
@@ -30,7 +32,8 @@ export function PlanPricingGrid({
const rights = getPlanRights(id, t, formatPrice);
const current = currentId === id;
const popular = id === highlighted;
- const cta = planCtaLabel(currentId, id, t);
+ const manage = current && currentId !== "free";
+ const cta = manage ? t("plans.manage") : planCtaLabel(currentId, id, t);
return (
onChoose(id)}
>
{cta}
diff --git a/apps/web/src/data/DataContext.tsx b/apps/web/src/data/DataContext.tsx
index 4e7a13f..f27ae1e 100644
--- a/apps/web/src/data/DataContext.tsx
+++ b/apps/web/src/data/DataContext.tsx
@@ -16,7 +16,7 @@ const DataContext = createContext(null);
export function DataProvider({ children }: { children: ReactNode }) {
const [tick, setTick] = useState(0);
const refresh = useCallback(() => setTick((t) => t + 1), []);
- const repos = useMemo(() => createRepos(), [tick]);
+ const repos = useMemo(() => createRepos(), []);
const value = useMemo(
() => ({ repos, dataSource: "live" as const, refresh, tick }),
diff --git a/apps/web/src/data/live/http.ts b/apps/web/src/data/live/http.ts
index cb5e7e7..ea49449 100644
--- a/apps/web/src/data/live/http.ts
+++ b/apps/web/src/data/live/http.ts
@@ -97,7 +97,7 @@ async function tryRefresh(): Promise {
saveTokens(next);
return next;
} catch {
- saveTokens(null);
+ // A temporary network failure is not proof that the refresh token is invalid.
return null;
} finally {
refreshPromise = null;
diff --git a/apps/web/src/data/live/m5Repos.ts b/apps/web/src/data/live/m5Repos.ts
index ba2d89e..5154c3b 100644
--- a/apps/web/src/data/live/m5Repos.ts
+++ b/apps/web/src/data/live/m5Repos.ts
@@ -185,6 +185,7 @@ function mapScoutPost(raw: Record): ScoutPost {
classification: raw.classification != null ? String(raw.classification) : undefined,
permalink: raw.permalink != null ? String(raw.permalink) : undefined,
created_at: raw.created_at != null ? Number(raw.created_at) : undefined,
+ published_at: raw.published_at != null ? Number(raw.published_at) : undefined,
};
}
@@ -265,10 +266,6 @@ export function createLiveInspiration(): InspirationRepo {
const raw = await apiRequest>("/api/v1/inspire/session");
return mapSession(raw);
},
- async saveSession(session) {
- // backend has no separate save; chat writes session. Persist via clear+noop — return as-is for UI
- return session;
- },
async clearSession() {
const raw = await apiRequest>("/api/v1/inspire/session/clear", {
method: "POST",
diff --git a/apps/web/src/data/live/repos.ts b/apps/web/src/data/live/repos.ts
index 5489bb6..78f0516 100644
--- a/apps/web/src/data/live/repos.ts
+++ b/apps/web/src/data/live/repos.ts
@@ -24,9 +24,11 @@ import type {
PlanId,
UsageEvent,
UsageKeyMode,
+ UsageGranularity,
UsageMemberPrefs,
UsageMeter,
UsageMonthSummary,
+ TenantUsageAnalytics,
} from "../../lib/usageMeter";
import { PLANS } from "../../lib/usageMeter";
import type {
@@ -47,6 +49,9 @@ import type {
SettingsRepo,
ThreadsPlatformStatus,
UsageRepo,
+ BillingCheckoutSession,
+ BillingCheckoutStatus,
+ BillingSubscription,
} from "../repos";
import {
createLiveInspiration,
@@ -55,7 +60,7 @@ import {
createLiveScout,
} from "./m5Repos";
import { createLiveInviteRepo } from "./inviteRepo";
-import { apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http";
+import { ApiError, apiRequest, loadTokens, saveTokens, type StoredTokens } from "./http";
function uidToString(uid: unknown): string {
if (typeof uid === "number" || typeof uid === "bigint") return String(uid);
@@ -63,6 +68,39 @@ function uidToString(uid: unknown): string {
return String(uid ?? "");
}
+function billingPlanId(value: unknown): PlanId {
+ const id = String(value ?? "free");
+ return id === "starter" || id === "pro" ? id : "free";
+}
+
+export function mapBillingCheckoutSession(raw: Record): BillingCheckoutSession {
+ return {
+ id: String(raw.id ?? ""),
+ session_id: String(raw.session_id ?? ""),
+ url: String(raw.url ?? ""),
+ expires_at: Number(raw.expires_at ?? 0),
+ };
+}
+
+export function mapBillingCheckoutStatus(raw: Record): BillingCheckoutStatus {
+ return {
+ id: String(raw.id ?? ""),
+ plan_id: billingPlanId(raw.plan_id),
+ checkout_status: String(raw.checkout_status ?? raw.status ?? ""),
+ payment_status: String(raw.payment_status ?? ""),
+ fulfillment_status: String(raw.fulfillment_status ?? ""),
+ };
+}
+
+export function mapBillingSubscription(raw: Record): BillingSubscription {
+ return {
+ plan_id: billingPlanId(raw.plan_id),
+ status: String(raw.status ?? ""),
+ current_period_end: Number(raw.current_period_end ?? 0),
+ cancel_at_period_end: Boolean(raw.cancel_at_period_end),
+ };
+}
+
function normalizeMember(raw: Record): Member {
const identitiesRaw = Array.isArray(raw.identities) ? raw.identities : [];
return {
@@ -162,8 +200,10 @@ function createLiveAuth(): AuthRepo {
try {
const raw = await apiRequest>("/api/v1/auth/me");
return normalizeMember(raw);
- } catch {
- saveTokens(null);
+ } catch (error) {
+ if (error instanceof ApiError && error.httpStatus === 401) {
+ saveTokens(null);
+ }
return null;
}
},
@@ -620,7 +660,6 @@ function createLiveNotifications(): NotificationsRepo {
function mapMeterBlock(
plan: (typeof PLANS)[PlanId],
platformBy: Record | undefined,
- _byokBy: Record | undefined,
): UsageMonthSummary["by_meter"] {
const by_meter = {} as UsageMonthSummary["by_meter"];
for (const m of Object.keys(plan.soft_caps) as UsageMeter[]) {
@@ -640,6 +679,82 @@ function mapMeterBlock(
return by_meter;
}
+export function mapByokMeterBlock(
+ plan: (typeof PLANS)[PlanId],
+ raw: Record | undefined,
+): UsageMonthSummary["byok"]["by_meter"] {
+ const by_meter = {} as UsageMonthSummary["byok"]["by_meter"];
+ for (const m of Object.keys(plan.soft_caps) as UsageMeter[]) {
+ const row = (raw?.[m] as Record | undefined) ?? {};
+ by_meter[m] = {
+ credits: Number(row.credits ?? 0),
+ count: Number(row.count ?? 0),
+ };
+ }
+ return by_meter;
+}
+
+function mapAnalyticsMeterBlock(
+ raw: Record | undefined,
+): Record {
+ const mapped = {} as Record;
+ for (const meter of Object.keys(PLANS.free.soft_caps) as UsageMeter[]) {
+ const row = (raw?.[meter] as Record | undefined) ?? {};
+ mapped[meter] = {
+ credits: Number(row.credits ?? 0),
+ count: Number(row.count ?? 0),
+ };
+ }
+ return mapped;
+}
+
+export function mapTenantAnalytics(raw: Record): TenantUsageAnalytics {
+ const byok = (raw.byok as Record | undefined) ?? {};
+ return {
+ granularity: String(raw.granularity ?? "month") as UsageGranularity,
+ from: String(raw.from ?? ""),
+ to: String(raw.to ?? ""),
+ range_label: String(raw.range_label ?? ""),
+ purchased_credits: Number(raw.purchased_credits ?? 0),
+ consumed_credits: Number(raw.consumed_credits ?? 0),
+ remaining_credits: Number(raw.remaining_credits ?? 0),
+ pct: Number(raw.pct ?? 0),
+ ai_calls: Number(raw.ai_calls ?? 0),
+ search_calls: Number(raw.search_calls ?? 0),
+ by_meter: mapAnalyticsMeterBlock(raw.by_meter as Record | undefined),
+ byok: {
+ call_count: Number(byok.call_count ?? 0),
+ by_meter: mapAnalyticsMeterBlock(byok.by_meter as Record | undefined),
+ },
+ series: ((raw.series as Record[] | undefined) ?? []).map((row) => ({
+ key: String(row.key ?? ""),
+ label: String(row.label ?? row.key ?? ""),
+ purchased_credits: Number(row.purchased_credits ?? 0),
+ consumed_credits: Number(row.consumed_credits ?? 0),
+ ai_calls: Number(row.ai_calls ?? 0),
+ search_calls: Number(row.search_calls ?? 0),
+ byok_call_count: Number(row.byok_call_count ?? 0),
+ })),
+ members: ((raw.members as Record[] | undefined) ?? []).map((row) => {
+ const planIdRaw = String(row.plan_id ?? "free").toLowerCase();
+ const planId = (planIdRaw in PLANS ? planIdRaw : "free") as PlanId;
+ return {
+ uid: String(row.uid ?? ""),
+ email: String(row.email ?? ""),
+ display_name: String(row.display_name ?? row.email ?? row.uid ?? ""),
+ unlimited: Boolean(row.unlimited),
+ plan_id: planId,
+ purchased_credits: Number(row.purchased_credits ?? 0),
+ total_credits: Number(row.total_credits ?? 0),
+ ai_calls: Number(row.ai_calls ?? 0),
+ search_calls: Number(row.search_calls ?? 0),
+ remaining_credits: Number(row.remaining_credits ?? 0),
+ pct: Number(row.pct ?? 0),
+ };
+ }),
+ };
+}
+
function mapUsageEvent(raw: Record): UsageEvent {
return {
id: String(raw.id ?? ""),
@@ -667,14 +782,15 @@ function createLiveUsage(): UsageRepo {
const byok = (raw.byok as Record) ?? {};
const cap = plan.monthly_credits;
const used = Number(platform.credits_used ?? 0);
- const remaining = Boolean(raw.unlimited)
+ const remaining = raw.unlimited
? Math.max(0, Number(platform.credits_remaining ?? Math.max(0, cap - used)))
: Math.max(0, cap - used);
const pct = cap > 0 ? Math.min(999, Math.round((used / cap) * 100)) : 0;
const platBy = (platform.by_meter as Record | undefined) ?? {};
const byokBy = (byok.by_meter as Record | undefined) ?? {};
- const by_meter = mapMeterBlock(plan, platBy, byokBy);
+ const by_meter = mapMeterBlock(plan, platBy);
+ const byok_by_meter = mapByokMeterBlock(plan, byokBy);
// 明細列表:與摘要一併載入(用量頁 ledger / 細項)
let events: UsageEvent[] = [];
@@ -716,7 +832,10 @@ function createLiveUsage(): UsageRepo {
credits_total: cap,
call_count: Number(platform.call_count ?? 0),
},
- byok: { call_count: Number(byok.call_count ?? 0) },
+ byok: {
+ call_count: Number(byok.call_count ?? 0),
+ by_meter: byok_by_meter,
+ },
};
},
async listEvents(limit = 100) {
@@ -819,53 +938,37 @@ function createLiveUsage(): UsageRepo {
};
},
async getTenantAnalytics(query) {
- const sum = await this.getTenantSummary();
- const purchased = sum.purchased_credits || 1;
- return {
- granularity: (query?.granularity ?? "month") as "day" | "month" | "year",
- from: query?.from ?? "",
- to: query?.to ?? "",
- range_label: sum.month_key,
- purchased_credits: sum.purchased_credits,
- consumed_credits: sum.consumed_credits,
- remaining_credits: sum.remaining_credits,
- pct:
- sum.purchased_credits > 0
- ? Math.min(999, Math.round((sum.consumed_credits / purchased) * 100))
- : 0,
- ai_calls: sum.ai_calls,
- search_calls: sum.search_calls,
- by_meter: sum.by_meter,
- // 後端目前僅月彙總;圖表至少給本月一點
- series: sum.month_key
- ? [
- {
- key: sum.month_key,
- label: sum.month_key,
- purchased_credits: sum.purchased_credits,
- consumed_credits: sum.consumed_credits,
- ai_calls: sum.ai_calls,
- search_calls: sum.search_calls,
- },
- ]
- : [],
- members: sum.members,
- };
+ const params = new URLSearchParams();
+ params.set("granularity", query?.granularity ?? "month");
+ if (query?.from) params.set("from", query.from);
+ if (query?.to) params.set("to", query.to);
+ const raw = await apiRequest>(
+ `/api/v1/usage/tenant-analytics?${params.toString()}`,
+ );
+ return mapTenantAnalytics(raw);
},
- async purchasePlan(plan_id, opts) {
- const raw = await apiRequest>("/api/v1/usage/purchase", {
+ async createCheckoutSession(plan_id, request_id) {
+ const raw = await apiRequest>(
+ "/api/v1/billing/checkout-sessions",
+ { method: "POST", body: { plan_id, request_id } },
+ );
+ return mapBillingCheckoutSession(raw);
+ },
+ async getCheckoutSession(id) {
+ const raw = await apiRequest>(
+ `/api/v1/billing/checkout-sessions/${encodeURIComponent(id)}`,
+ );
+ return mapBillingCheckoutStatus(raw);
+ },
+ async getBillingSubscription() {
+ const raw = await apiRequest>("/api/v1/billing/subscription");
+ return mapBillingSubscription(raw);
+ },
+ async createBillingPortalSession() {
+ const raw = await apiRequest>("/api/v1/billing/portal-sessions", {
method: "POST",
- body: { plan_id, mock_ref: opts?.mock_ref },
});
- return {
- id: String(raw.id ?? ""),
- uid: "",
- plan_id: String(raw.plan_id ?? plan_id) as PlanId,
- amount_twd: Number(raw.amount_twd ?? 0),
- status: "paid",
- mock_ref: raw.mock_ref != null ? String(raw.mock_ref) : undefined,
- created_at: Number(raw.created_at ?? 0),
- };
+ return { url: String(raw.url ?? "") };
},
async listMyPurchases(limit = 20) {
const data = await apiRequest<{ list: Record[] }>(
diff --git a/apps/web/src/data/live/usageMapping.test.ts b/apps/web/src/data/live/usageMapping.test.ts
new file mode 100644
index 0000000..652fb68
--- /dev/null
+++ b/apps/web/src/data/live/usageMapping.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { PLANS } from "../../lib/usageMeter";
+import {
+ createLiveRepos,
+ mapBillingCheckoutSession,
+ mapBillingCheckoutStatus,
+ mapBillingSubscription,
+ mapByokMeterBlock,
+ mapTenantAnalytics,
+} from "./repos";
+
+describe("billing API mapping", () => {
+ it("maps checkout creation, status, and subscription responses", () => {
+ expect(
+ mapBillingCheckoutSession({ id: 12, session_id: "cs_1", url: "https://checkout.stripe.com/x", expires_at: "42" }),
+ ).toEqual({ id: "12", session_id: "cs_1", url: "https://checkout.stripe.com/x", expires_at: 42 });
+ expect(
+ mapBillingCheckoutStatus({ id: "local_1", plan_id: "pro", checkout_status: "complete", payment_status: "paid", fulfillment_status: "fulfilled" }),
+ ).toEqual({ id: "local_1", plan_id: "pro", checkout_status: "complete", payment_status: "paid", fulfillment_status: "fulfilled" });
+ expect(mapBillingCheckoutStatus({ status: "expired" }).checkout_status).toBe("expired");
+ expect(
+ mapBillingSubscription({ plan_id: "starter", status: "active", current_period_end: "99", cancel_at_period_end: true }),
+ ).toEqual({ plan_id: "starter", status: "active", current_period_end: 99, cancel_at_period_end: true });
+ });
+
+ it("uses the exact billing endpoints and checkout request fields", async () => {
+ const originalFetch = globalThis.fetch;
+ const requests: Array<{ url: string; method: string; body: string | null }> = [];
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ requests.push({ url: String(input), method: init?.method ?? "GET", body: (init?.body as string) ?? null });
+ const url = String(input);
+ const data = url.endsWith("portal-sessions")
+ ? { url: "https://billing.stripe.com/x" }
+ : url.endsWith("subscription")
+ ? { plan_id: "free", status: "none", current_period_end: 0, cancel_at_period_end: false }
+ : url.endsWith("checkout-sessions")
+ ? { id: "local_1", session_id: "cs_1", url: "https://checkout.stripe.com/x", expires_at: 1 }
+ : { id: "local_1", plan_id: "starter", checkout_status: "open", payment_status: "unpaid", fulfillment_status: "pending" };
+ return new Response(JSON.stringify({ code: 102000, message: "ok", data, error: null }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }) as typeof fetch;
+ try {
+ const usage = createLiveRepos().usage;
+ await usage.createCheckoutSession("starter", "request-1");
+ await usage.getCheckoutSession("local/id");
+ await usage.getBillingSubscription();
+ await usage.createBillingPortalSession();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+
+ expect(requests.map((request) => [request.method, request.url])).toEqual([
+ ["POST", "/api/v1/billing/checkout-sessions"],
+ ["GET", "/api/v1/billing/checkout-sessions/local%2Fid"],
+ ["GET", "/api/v1/billing/subscription"],
+ ["POST", "/api/v1/billing/portal-sessions"],
+ ]);
+ expect(JSON.parse(requests[0]?.body ?? "{}")).toEqual({ plan_id: "starter", request_id: "request-1" });
+ });
+});
+
+describe("BYOK usage mapping", () => {
+ it("maps BYOK counts separately and fills absent meters with zero", () => {
+ const mapped = mapByokMeterBlock(PLANS.free, {
+ ai_copy: { credits: 99, count: 3 },
+ web_search: { count: 2 },
+ });
+
+ expect(mapped.ai_copy).toEqual({ credits: 99, count: 3 });
+ expect(mapped.web_search).toEqual({ credits: 0, count: 2 });
+ expect(mapped.ai_research).toEqual({ credits: 0, count: 0 });
+ });
+});
+
+describe("tenant analytics mapping", () => {
+ it("keeps platform usage and BYOK counts in separate fields", () => {
+ const mapped = mapTenantAnalytics({
+ granularity: "day",
+ from: "2026-07-01",
+ to: "2026-07-02",
+ consumed_credits: 4,
+ ai_calls: 2,
+ by_meter: { ai_copy: { credits: 4, count: 2 } },
+ byok: { call_count: 3, by_meter: { web_search: { count: 3 } } },
+ series: [{ key: "2026-07-01", consumed_credits: 4, byok_call_count: 3 }],
+ members: [{ uid: 7, plan_id: "pro", total_credits: 4, purchased_credits: 10 }],
+ });
+
+ expect(mapped.consumed_credits).toBe(4);
+ expect(mapped.by_meter.ai_copy).toEqual({ credits: 4, count: 2 });
+ expect(mapped.byok.call_count).toBe(3);
+ expect(mapped.byok.by_meter.web_search).toEqual({ credits: 0, count: 3 });
+ expect(mapped.series[0]?.byok_call_count).toBe(3);
+ expect(mapped.members[0]?.uid).toBe("7");
+ });
+
+ it("calls the range endpoint with the selected query", async () => {
+ const originalFetch = globalThis.fetch;
+ let requested = "";
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ requested = String(input);
+ return new Response(
+ JSON.stringify({
+ code: 102000,
+ message: "ok",
+ data: { granularity: "year", from: "2024-01-01", to: "2026-07-13", series: [], members: [] },
+ error: null,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }) as typeof fetch;
+ try {
+ await createLiveRepos().usage.getTenantAnalytics({
+ granularity: "year",
+ from: "2024-01-01",
+ to: "2026-07-13",
+ });
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+
+ expect(requested).toContain("/api/v1/usage/tenant-analytics?");
+ expect(requested).toContain("granularity=year");
+ expect(requested).toContain("from=2024-01-01");
+ expect(requested).toContain("to=2026-07-13");
+ expect(requested).not.toContain("tenant-summary");
+ });
+});
diff --git a/apps/web/src/data/repos.ts b/apps/web/src/data/repos.ts
index 78af976..8495805 100644
--- a/apps/web/src/data/repos.ts
+++ b/apps/web/src/data/repos.ts
@@ -349,7 +349,6 @@ export type InspirationRepo = {
removeElement(id: string): Promise;
/** 目前作用中的 session */
getSession(): Promise;
- saveSession(session: InspireSession): Promise;
/**
* 相容舊 API:開新對話並切過去(舊 session 保留)。
* 建議 UI 用 createSession。
@@ -586,15 +585,38 @@ export type UsageRepo = {
getTenantSummary(monthKey?: string): Promise;
/** 管理員:全體區間分析(日/月/年 + 購買 vs 消耗) */
getTenantAnalytics(query?: TenantAnalyticsQuery): Promise;
- /**
- * 會員自己購買方案(mock:假付款成功後才改 plan)。
- * 管理員直接 setMemberPrefs 不算購買。
- */
- purchasePlan(plan_id: PlanId, opts?: { mock_ref?: string }): Promise;
+ createCheckoutSession(plan_id: PlanId, request_id: string): Promise;
+ getCheckoutSession(id: string): Promise;
+ getBillingSubscription(): Promise;
+ createBillingPortalSession(): Promise;
/** 自己的購買紀錄 */
listMyPurchases(limit?: number): Promise;
};
+export type BillingCheckoutSession = {
+ id: string;
+ session_id: string;
+ url: string;
+ expires_at: number;
+};
+
+export type BillingCheckoutStatus = {
+ id: string;
+ plan_id: PlanId;
+ checkout_status: string;
+ payment_status: string;
+ fulfillment_status: string;
+};
+
+export type BillingSubscription = {
+ plan_id: PlanId;
+ status: string;
+ current_period_end: number;
+ cancel_at_period_end: boolean;
+};
+
+export type BillingPortalSession = { url: string };
+
export type Repos = {
dataSource: DataSource;
auth: AuthRepo;
diff --git a/apps/web/src/domain/types.ts b/apps/web/src/domain/types.ts
index 77b67dd..912e888 100644
--- a/apps/web/src/domain/types.ts
+++ b/apps/web/src/domain/types.ts
@@ -693,6 +693,8 @@ export type ScoutPost = {
permalink?: string;
/** 掃描命中的建立時間(UTC unix nanoseconds) */
created_at?: number;
+ /** 實際發出回覆的時間(UTC unix nanoseconds);舊資料可能沒有 */
+ published_at?: number;
};
/**
diff --git a/apps/web/src/lib/apiErrors.ts b/apps/web/src/lib/apiErrors.ts
index e60b5ed..0d4dc68 100644
--- a/apps/web/src/lib/apiErrors.ts
+++ b/apps/web/src/lib/apiErrors.ts
@@ -224,7 +224,7 @@ export function useFormatApiError() {
* 例:verification code sent → 「驗證碼已寄出」
*/
export function useFormatApiOk() {
- const { t, locale } = useI18n();
+ const { t } = useI18n();
return useCallback(
(raw: string | undefined | null, fallbackKey = "common.success"): string => {
const text = (raw || "").trim();
@@ -233,6 +233,6 @@ export function useFormatApiOk() {
if (key) return t(key);
return text;
},
- [t, locale],
+ [t],
);
}
diff --git a/apps/web/src/lib/billingCheckout.test.ts b/apps/web/src/lib/billingCheckout.test.ts
new file mode 100644
index 0000000..29f614c
--- /dev/null
+++ b/apps/web/src/lib/billingCheckout.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it, vi } from "vitest";
+import type { BillingCheckoutStatus } from "../data/repos";
+import {
+ billingErrorMessage,
+ isSafeBillingUrl,
+ pollCheckout,
+ startBillingRedirect,
+} from "./billingCheckout";
+
+function checkout(overrides: Partial = {}): BillingCheckoutStatus {
+ return {
+ id: "local_1",
+ plan_id: "starter",
+ checkout_status: "complete",
+ payment_status: "paid",
+ fulfillment_status: "pending",
+ ...overrides,
+ };
+}
+
+describe("billing URL validation", () => {
+ it("allows HTTPS and only permits localhost HTTP in development", () => {
+ expect(isSafeBillingUrl("https://checkout.stripe.com/pay/cs_1", false)).toBe(true);
+ expect(isSafeBillingUrl("http://localhost:4242/pay", true)).toBe(true);
+ expect(isSafeBillingUrl("http://127.0.0.1:4242/pay", true)).toBe(true);
+ expect(isSafeBillingUrl("http://localhost:4242/pay", false)).toBe(false);
+ expect(isSafeBillingUrl("http://stripe.example/pay", true)).toBe(false);
+ expect(isSafeBillingUrl("javascript:alert(1)", true)).toBe(false);
+ });
+});
+
+describe("billing errors", () => {
+ const t = (key: string) => key;
+
+ it("maps transport, availability, and auth failures to localized keys", () => {
+ expect(billingErrorMessage({ code: 0, httpStatus: 0 }, t, "checkout.fail")).toBe("checkout.networkError");
+ expect(billingErrorMessage({ code: 503001, httpStatus: 503 }, t, "checkout.fail")).toBe("checkout.unavailable");
+ expect(billingErrorMessage({ code: 401000, httpStatus: 401 }, t, "checkout.fail")).toBe("checkout.sessionExpired");
+ expect(billingErrorMessage({ code: 409081, httpStatus: 409 }, t, "checkout.fail")).toBe("checkout.portalUnavailable");
+ expect(billingErrorMessage(new Error("raw backend text"), t, "checkout.fail")).toBe("checkout.fail");
+ });
+});
+
+describe("billing redirect", () => {
+ it("reports an obvious failure when the browser stays on the page", () => {
+ vi.useFakeTimers();
+ const failed = vi.fn();
+ startBillingRedirect(() => undefined, failed, 100);
+ vi.advanceTimersByTime(100);
+ expect(failed).toHaveBeenCalledOnce();
+ vi.useRealTimers();
+ });
+
+ it("reports synchronous navigation failures immediately", () => {
+ const failed = vi.fn();
+ startBillingRedirect(() => {
+ throw new Error("blocked");
+ }, failed);
+ expect(failed).toHaveBeenCalledOnce();
+ });
+
+ it("does not report failure after the page starts unloading", () => {
+ vi.useFakeTimers();
+ const failed = vi.fn();
+ startBillingRedirect(() => window.dispatchEvent(new Event("pagehide")), failed, 100);
+ vi.advanceTimersByTime(100);
+ expect(failed).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+});
+
+describe("checkout polling", () => {
+ it("waits until fulfillment, not merely successful payment", async () => {
+ const getCheckout = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(checkout())
+ .mockResolvedValueOnce(checkout({ fulfillment_status: "fulfilled" }));
+ const result = await pollCheckout(getCheckout, { sleep: async () => undefined });
+ expect(result?.outcome).toBe("fulfilled");
+ expect(getCheckout).toHaveBeenCalledTimes(2);
+ });
+
+ it("returns terminal failures immediately", async () => {
+ const result = await pollCheckout(
+ async () => checkout({ checkout_status: "expired", payment_status: "unpaid" }),
+ { sleep: async () => undefined },
+ );
+ expect(result?.outcome).toBe("failed");
+ });
+
+ it("times out after the configured duration", async () => {
+ const getCheckout = vi.fn(async () => checkout());
+ const result = await pollCheckout(getCheckout, {
+ intervalMs: 1_500,
+ timeoutMs: 3_000,
+ sleep: async () => undefined,
+ });
+ expect(result?.outcome).toBe("timeout");
+ expect(getCheckout).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/apps/web/src/lib/billingCheckout.ts b/apps/web/src/lib/billingCheckout.ts
new file mode 100644
index 0000000..fdf9ac7
--- /dev/null
+++ b/apps/web/src/lib/billingCheckout.ts
@@ -0,0 +1,112 @@
+import type { BillingCheckoutStatus } from "../data/repos";
+
+type Translate = (key: string, params?: Record) => string;
+
+type ApiErrorLike = {
+ code?: unknown;
+ httpStatus?: unknown;
+};
+
+export type CheckoutPollResult =
+ | { outcome: "fulfilled"; checkout: BillingCheckoutStatus }
+ | { outcome: "failed"; checkout: BillingCheckoutStatus }
+ | { outcome: "timeout"; checkout: BillingCheckoutStatus };
+
+export function isSafeBillingUrl(value: string, dev = import.meta.env.DEV): boolean {
+ try {
+ const url = new URL(value);
+ if (url.protocol === "https:") return true;
+ return (
+ dev &&
+ url.protocol === "http:" &&
+ (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]")
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function billingErrorMessage(
+ error: unknown,
+ t: Translate,
+ fallbackKey: string,
+): string {
+ const apiError = error && typeof error === "object" ? (error as ApiErrorLike) : null;
+ const isApiError = apiError && ("code" in apiError || "httpStatus" in apiError);
+ if (!isApiError) return t(fallbackKey);
+ const code = typeof apiError?.code === "number" ? apiError.code : 0;
+ const status = typeof apiError?.httpStatus === "number" ? apiError.httpStatus : 0;
+ if (status === 0 && code === 0) return t("checkout.networkError");
+ if (status === 503 || status === 504 || code === 503010) return t("checkout.unavailable");
+ if (status === 401) return t("checkout.sessionExpired");
+ if (code === 409081) return t("checkout.portalUnavailable");
+ return t(fallbackKey);
+}
+
+export function startBillingRedirect(
+ assign: () => void,
+ onFailure: () => void,
+ timeoutMs = 3_000,
+): () => void {
+ let settled = false;
+ let timer = 0;
+ const cleanup = () => {
+ settled = true;
+ window.clearTimeout(timer);
+ window.removeEventListener("pagehide", cleanup);
+ };
+ window.addEventListener("pagehide", cleanup, { once: true });
+ timer = window.setTimeout(() => {
+ if (settled) return;
+ cleanup();
+ onFailure();
+ }, timeoutMs);
+ try {
+ assign();
+ } catch {
+ cleanup();
+ onFailure();
+ }
+ return cleanup;
+}
+
+export function isTerminalCheckoutFailure(checkout: BillingCheckoutStatus): boolean {
+ const checkoutStatus = checkout.checkout_status.toLowerCase();
+ const paymentStatus = checkout.payment_status.toLowerCase();
+ const fulfillmentStatus = checkout.fulfillment_status.toLowerCase();
+ return (
+ ["failed", "expired", "canceled", "cancelled"].includes(checkoutStatus) ||
+ ["failed", "expired", "canceled", "cancelled"].includes(paymentStatus) ||
+ ["failed", "expired", "canceled", "cancelled"].includes(fulfillmentStatus)
+ );
+}
+
+export async function pollCheckout(
+ getCheckout: () => Promise,
+ options: {
+ intervalMs?: number;
+ timeoutMs?: number;
+ sleep?: (ms: number) => Promise;
+ stopped?: () => boolean;
+ } = {},
+): Promise {
+ const intervalMs = options.intervalMs ?? 1_500;
+ const timeoutMs = options.timeoutMs ?? 30_000;
+ const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
+ let elapsed = 0;
+ let checkout = await getCheckout();
+
+ while (!options.stopped?.()) {
+ if (checkout.fulfillment_status.toLowerCase() === "fulfilled") {
+ return { outcome: "fulfilled", checkout };
+ }
+ if (isTerminalCheckoutFailure(checkout)) return { outcome: "failed", checkout };
+ if (elapsed >= timeoutMs) return { outcome: "timeout", checkout };
+ const wait = Math.min(intervalMs, timeoutMs - elapsed);
+ await sleep(wait);
+ elapsed += wait;
+ if (options.stopped?.()) return null;
+ checkout = await getCheckout();
+ }
+ return null;
+}
diff --git a/apps/web/src/lib/externalUrl.ts b/apps/web/src/lib/externalUrl.ts
new file mode 100644
index 0000000..0079441
--- /dev/null
+++ b/apps/web/src/lib/externalUrl.ts
@@ -0,0 +1,9 @@
+export function allowHttpUrl(value: string | null | undefined): string | null {
+ if (!value) return null;
+ try {
+ const url = new URL(value);
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/web/src/lib/i18n/messages.ts b/apps/web/src/lib/i18n/messages.ts
index 301351b..b760cbd 100644
--- a/apps/web/src/lib/i18n/messages.ts
+++ b/apps/web/src/lib/i18n/messages.ts
@@ -213,6 +213,8 @@ export const zhTW: MessageDict = {
"usage.meter.cap": "單項上限 {credits}/{cap} 點",
"usage.side.aiCredits": "AI 相關已用點數(文案+研究+生圖)",
"usage.side.searchCredits": "搜尋已用點數",
+ "usage.byok.title": "自備 Key 用量",
+ "usage.byok.hint": "只計呼叫次數,不占平台點數與分項進度。",
"usage.plan.free.blurb": "夠用試用,體驗完整創作流程",
"usage.plan.starter.blurb": "小團隊日常發文與海巡",
"usage.plan.pro.blurb": "多帳、重度 AI 與研究",
@@ -443,6 +445,7 @@ export const zhTW: MessageDict = {
"outbox.confirmDelete": "刪除發送項目「{title}」?\n無法復原。",
"outbox.deleted": "已刪除「{title}」",
"outbox.deleteFail": "刪除失敗",
+ "outbox.loadFail": "載入發送列表失敗",
"outbox.status.scheduling": "排程中",
"outbox.status.active": "發送中",
"outbox.status.completed": "已完成",
@@ -499,7 +502,9 @@ export const zhTW: MessageDict = {
"mentions.repliedPrefix": "已回:{text}",
"mentions.needPersona": "請選 ready 人設再 AI 產文",
"mentions.fail": "失敗",
- "mentions.marked": "已發回覆到 Threads(@{user})",
+ "mentions.marked": "已將這則提及標記為已回覆",
+ "mentions.markReplied": "標記為已回覆",
+ "mentions.markingReplied": "標記中…",
"mentions.withImages": " · 附圖 {n}",
"compose.hint": "純發文:寫正文後送出 Outbox(非串場)。互回請用",
@@ -790,6 +795,8 @@ export const zhTW: MessageDict = {
"plans.inUse": "使用中",
"plans.recommended": "推薦",
"plans.creditsPerMonth": "每月 {n} 點",
+ "plans.manage": "管理訂閱",
+ "plans.loadFail": "無法載入訂閱方案",
"plan.cta.current": "目前方案",
"plan.cta.upgrade": "升級",
@@ -805,8 +812,8 @@ export const zhTW: MessageDict = {
"plan.free.right2": "點數夠你真實試跑文案、搜尋與幾次生圖,不是空殼 demo。",
"plan.free.right3": "達上限後升級 Starter 繼續;或設定自備 Key(BYOK)不占平台點。",
"plan.free.quota1": "每月配給 {n} 點。",
- "plan.free.note1": "無需付款,確認即切換。",
- "plan.free.note2": "正式金流後的發票規則另訂。",
+ "plan.free.note1": "Free 使用者無需付款;付費使用者請由帳務入口管理取消。",
+ "plan.free.note2": "取消付費訂閱後,方案依帳務入口顯示的日期切換。",
"plan.starter.headline": "小團隊日常發文與海巡",
"plan.starter.bullet1": "每月 {n} 點(約 5× Free)",
@@ -839,8 +846,6 @@ export const zhTW: MessageDict = {
"checkout.title": "確認方案",
"checkout.pickFirst": "請先選擇方案。",
"checkout.viewPlans": "看方案",
- "checkout.cardLast4Required": "請填卡號末四碼",
- "checkout.cardNameRequired": "請填持卡人",
"checkout.fail": "無法完成",
"checkout.confirmFree": "確認切換至 Free",
"checkout.payAndAction": "{action}並付款 {price}",
@@ -850,8 +855,6 @@ export const zhTW: MessageDict = {
"checkout.youGet": "你會得到",
"checkout.quota": "額度",
"checkout.notes": "注意",
- "checkout.cardholder": "持卡人",
- "checkout.cardLast4": "卡號末四碼",
"checkout.amountDue": "應付金額",
"checkout.billedMonthly": "{name} · 按月計費",
"checkout.already": "已是此方案",
@@ -859,6 +862,23 @@ export const zhTW: MessageDict = {
"checkout.currentPlan": "目前方案",
"checkout.pickOther": "改選其他方案",
"checkout.cancel": "取消",
+ "checkout.invalidUrl": "付款服務回傳了不安全的網址,未進行跳轉。",
+ "checkout.redirecting": "正在前往 Stripe 安全付款頁面…",
+ "checkout.redirectingPortal": "正在前往 Stripe 訂閱管理頁面…",
+ "checkout.redirectFailed": "無法開啟 Stripe 頁面。請檢查瀏覽器或網路設定後再試一次。",
+ "checkout.networkError": "無法連線至帳務服務。請檢查網路後再試一次。",
+ "checkout.unavailable": "帳務服務目前尚未啟用或暫時無法使用,請稍後再試。",
+ "checkout.sessionExpired": "登入狀態已失效,請重新登入後再試。",
+ "checkout.portalUnavailable": "目前沒有可管理的 Stripe 訂閱。請先選擇付費方案。",
+ "checkout.verifying": "正在確認付款與方案生效狀態…",
+ "checkout.pollFail": "無法查詢付款狀態,請重試。",
+ "checkout.missingId": "缺少結帳編號,無法確認付款結果。",
+ "checkout.terminalFail": "結帳未完成({status})。你可以重新選擇方案再試一次。",
+ "checkout.timeout": "付款可能仍在處理中,但方案尚未於 30 秒內生效。請重試查詢;請勿重複付款。",
+ "checkout.retry": "重試查詢",
+ "checkout.canceledTitle": "已取消結帳",
+ "checkout.canceledBody": "未變更方案,也未執行任何扣款操作。",
+ "checkout.manageInstead": "此方案異動請在帳務入口管理,避免建立重複訂閱。",
"usage.widget.titleUsed": "{name} · 已用 {used}/{cap} 點",
"usage.widget.titleUnlimited": "{name} · 不擋額度",
@@ -939,6 +959,10 @@ export const zhTW: MessageDict = {
"settings.modelsLoaded": "已取得 {provider} 模型清單",
"settings.aiSaved": "AI 設定已儲存",
"settings.searchSaved": "搜尋設定已儲存",
+ "settings.clearAiKey": "清除自備 AI Key",
+ "settings.aiKeyCleared": "自備 AI Key 已清除",
+ "settings.clearExaKey": "清除 Exa Key",
+ "settings.exaKeyCleared": "Exa Key 已清除",
"settings.searchProvider": "搜尋 Provider",
"settings.expand": "延伸策略",
"settings.exaKey": "Exa API Key",
@@ -1337,6 +1361,8 @@ export const zhTW: MessageDict = {
"inspire.pinnedAria": "本輪參考(給 AI 看)",
"inspire.pinned": "本輪參考",
"inspire.pinnedCount": "· {n}",
+ "inspire.pinsLocalShort": "本次工作階段",
+ "inspire.pinsSessionLocal": "參考項目只套用於本次工作階段;送出訊息時會一併帶入。",
"inspire.pickRight": "右側點選=掛給 AI;點名稱可插入輸入",
"inspire.unpinTitle": "取消參考",
"inspire.insertPinTitle": "插入主輸入",
@@ -1890,6 +1916,8 @@ export const en: MessageDict = {
"usage.meter.cap": "Cap {credits}/{cap} credits",
"usage.side.aiCredits": "AI credits used (copy + research + image)",
"usage.side.searchCredits": "Search credits used",
+ "usage.byok.title": "BYOK usage",
+ "usage.byok.hint": "Calls only; does not affect platform credits or progress bars.",
"usage.plan.free.blurb": "Enough to try the full creation flow",
"usage.plan.starter.blurb": "Small teams posting and patrolling daily",
"usage.plan.pro.blurb": "Multi-account, heavy AI and research",
@@ -2120,6 +2148,7 @@ export const en: MessageDict = {
"outbox.confirmDelete": "Delete outbox item “{title}”?\nThis cannot be undone.",
"outbox.deleted": "Deleted “{title}”",
"outbox.deleteFail": "Delete failed",
+ "outbox.loadFail": "Failed to load outbox",
"outbox.status.scheduling": "Scheduling",
"outbox.status.active": "Sending",
"outbox.status.completed": "Completed",
@@ -2176,7 +2205,9 @@ export const en: MessageDict = {
"mentions.repliedPrefix": "Replied: {text}",
"mentions.needPersona": "Pick a ready persona before AI draft",
"mentions.fail": "Failed",
- "mentions.marked": "Published reply to Threads (@{user})",
+ "mentions.marked": "Mention marked as replied",
+ "mentions.markReplied": "Mark as replied",
+ "mentions.markingReplied": "Marking…",
"mentions.withImages": " · {n} images",
"compose.hint": "Publish only: write body and send to Outbox (not a play). For multi-account threads use",
@@ -2467,6 +2498,8 @@ export const en: MessageDict = {
"plans.inUse": "Active",
"plans.recommended": "Recommended",
"plans.creditsPerMonth": "{n} credits / month",
+ "plans.manage": "Manage subscription",
+ "plans.loadFail": "Could not load your subscription",
"plan.cta.current": "Current plan",
"plan.cta.upgrade": "Upgrade",
@@ -2482,8 +2515,8 @@ export const en: MessageDict = {
"plan.free.right2": "Enough credits for real copy, search, and a few images—not a hollow demo.",
"plan.free.right3": "After the cap, upgrade to Starter—or set BYOK so you don't use platform credits.",
"plan.free.quota1": "{n} credits allocated each month.",
- "plan.free.note1": "No payment required; confirms immediately.",
- "plan.free.note2": "Invoice rules will be defined when billing goes live.",
+ "plan.free.note1": "Free requires no payment; paid subscribers manage cancellation in the billing portal.",
+ "plan.free.note2": "After cancellation, the plan changes on the date shown in the billing portal.",
"plan.starter.headline": "Small teams posting and patrolling daily",
"plan.starter.bullet1": "{n} credits / month (about 5× Free)",
@@ -2516,8 +2549,6 @@ export const en: MessageDict = {
"checkout.title": "Confirm plan",
"checkout.pickFirst": "Please choose a plan first.",
"checkout.viewPlans": "View plans",
- "checkout.cardLast4Required": "Enter the last 4 digits of your card",
- "checkout.cardNameRequired": "Enter the cardholder name",
"checkout.fail": "Could not complete",
"checkout.confirmFree": "Confirm switch to Free",
"checkout.payAndAction": "{action} and pay {price}",
@@ -2527,8 +2558,6 @@ export const en: MessageDict = {
"checkout.youGet": "What you get",
"checkout.quota": "Quota",
"checkout.notes": "Notes",
- "checkout.cardholder": "Cardholder",
- "checkout.cardLast4": "Last 4 digits",
"checkout.amountDue": "Amount due",
"checkout.billedMonthly": "{name} · billed monthly",
"checkout.already": "Already on this plan",
@@ -2536,6 +2565,23 @@ export const en: MessageDict = {
"checkout.currentPlan": "Current plan",
"checkout.pickOther": "Choose another plan",
"checkout.cancel": "Cancel",
+ "checkout.invalidUrl": "The billing service returned an unsafe URL. No redirect was made.",
+ "checkout.redirecting": "Taking you to Stripe's secure checkout…",
+ "checkout.redirectingPortal": "Taking you to the Stripe subscription portal…",
+ "checkout.redirectFailed": "Stripe could not be opened. Check your browser or network settings and try again.",
+ "checkout.networkError": "Could not connect to the billing service. Check your network and try again.",
+ "checkout.unavailable": "Billing is not enabled or is temporarily unavailable. Please try again later.",
+ "checkout.sessionExpired": "Your session has expired. Sign in again and retry.",
+ "checkout.portalUnavailable": "There is no Stripe subscription to manage yet. Choose a paid plan first.",
+ "checkout.verifying": "Confirming payment and plan activation…",
+ "checkout.pollFail": "Could not check the payment status. Please retry.",
+ "checkout.missingId": "The checkout ID is missing, so the payment result cannot be verified.",
+ "checkout.terminalFail": "Checkout did not complete ({status}). Choose a plan and try again.",
+ "checkout.timeout": "Payment may still be processing, but the plan was not activated within 30 seconds. Retry the status check; do not pay again.",
+ "checkout.retry": "Retry status check",
+ "checkout.canceledTitle": "Checkout canceled",
+ "checkout.canceledBody": "Your plan was not changed and no payment action was performed.",
+ "checkout.manageInstead": "Manage this change in the billing portal to avoid a duplicate subscription.",
"usage.widget.titleUsed": "{name} · used {used}/{cap} credits",
"usage.widget.titleUnlimited": "{name} · unlimited",
@@ -2616,6 +2662,10 @@ export const en: MessageDict = {
"settings.modelsLoaded": "Loaded models for {provider}",
"settings.aiSaved": "AI settings saved",
"settings.searchSaved": "Search settings saved",
+ "settings.clearAiKey": "Clear personal AI key",
+ "settings.aiKeyCleared": "Personal AI key cleared",
+ "settings.clearExaKey": "Clear Exa key",
+ "settings.exaKeyCleared": "Exa key cleared",
"settings.searchProvider": "Search provider",
"settings.expand": "Expand strategy",
"settings.exaKey": "Exa API key",
@@ -3014,6 +3064,8 @@ export const en: MessageDict = {
"inspire.pinnedAria": "This-round references (for the AI)",
"inspire.pinned": "References",
"inspire.pinnedCount": "· {n}",
+ "inspire.pinsLocalShort": "this session",
+ "inspire.pinsSessionLocal": "References apply to this session only and will be included with your next message.",
"inspire.pickRight": "Pick on the right = pin for AI; click name to insert",
"inspire.unpinTitle": "Remove reference",
"inspire.insertPinTitle": "Insert into input",
diff --git a/apps/web/src/lib/usageMeter.ts b/apps/web/src/lib/usageMeter.ts
index 6d7abe6..ed6f41b 100644
--- a/apps/web/src/lib/usageMeter.ts
+++ b/apps/web/src/lib/usageMeter.ts
@@ -194,6 +194,7 @@ export type UsageMonthSummary = {
/** BYOK:僅次數,不進點數條 */
byok: {
call_count: number;
+ by_meter: Record;
};
};
@@ -237,6 +238,7 @@ export type UsageTimeBucket = {
consumed_credits: number;
ai_calls: number;
search_calls: number;
+ byok_call_count: number;
};
export type TenantUsageAnalytics = {
@@ -252,6 +254,10 @@ export type TenantUsageAnalytics = {
ai_calls: number;
search_calls: number;
by_meter: Record;
+ byok: {
+ call_count: number;
+ by_meter: Record;
+ };
series: UsageTimeBucket[];
members: TenantUsageMemberRow[];
};
@@ -434,10 +440,15 @@ function aggregateEvents(
let platformCredits = 0;
let platformCalls = 0;
let byokCalls = 0;
+ const byokByMeter = {} as UsageMonthSummary["byok"]["by_meter"];
+ for (const m of Object.keys(METER_META) as UsageMeter[]) {
+ byokByMeter[m] = { credits: 0, count: 0 };
+ }
for (const e of events) {
const mode = e.key_mode ?? "platform";
if (mode === "byok") {
byokCalls += 1;
+ if (byokByMeter[e.meter]) byokByMeter[e.meter].count += 1;
continue;
}
platformCredits += e.credits;
@@ -482,7 +493,7 @@ function aggregateEvents(
credits_total: plan.monthly_credits,
call_count: platformCalls,
},
- byok: { call_count: byokCalls },
+ byok: { call_count: byokCalls, by_meter: byokByMeter },
};
}
@@ -759,11 +770,19 @@ export function summarizeTenantAnalytics(
});
const by_meter = {} as TenantUsageAnalytics["by_meter"];
+ const byokByMeter = {} as TenantUsageAnalytics["byok"]["by_meter"];
for (const m of Object.keys(METER_META) as UsageMeter[]) {
by_meter[m] = { credits: 0, count: 0 };
+ byokByMeter[m] = { credits: 0, count: 0 };
}
let consumed_credits = 0;
+ let byokCallCount = 0;
for (const e of rangeEvents) {
+ if (e.key_mode === "byok") {
+ byokCallCount += 1;
+ byokByMeter[e.meter].count += 1;
+ continue;
+ }
consumed_credits += e.credits;
const row = by_meter[e.meter];
if (row) {
@@ -792,6 +811,7 @@ export function summarizeTenantAnalytics(
let ai = 0;
let search = 0;
for (const e of evs) {
+ if (e.key_mode === "byok") continue;
c += e.credits;
if (METER_META[e.meter]?.group === "ai") ai += 1;
else search += 1;
@@ -803,6 +823,7 @@ export function summarizeTenantAnalytics(
consumed_credits: c,
ai_calls: ai,
search_calls: search,
+ byok_call_count: evs.filter((e) => e.key_mode === "byok").length,
};
});
@@ -814,6 +835,7 @@ export function summarizeTenantAnalytics(
let ai = 0;
let search = 0;
for (const e of evs) {
+ if (e.key_mode === "byok") continue;
total += e.credits;
if (METER_META[e.meter]?.group === "ai") ai += 1;
else search += 1;
@@ -854,6 +876,7 @@ export function summarizeTenantAnalytics(
ai_calls,
search_calls,
by_meter,
+ byok: { call_count: byokCallCount, by_meter: byokByMeter },
series,
members: memberRows,
};
diff --git a/apps/web/src/pages/AdminUsersPage.tsx b/apps/web/src/pages/AdminUsersPage.tsx
index 6e9f8b6..73a17df 100644
--- a/apps/web/src/pages/AdminUsersPage.tsx
+++ b/apps/web/src/pages/AdminUsersPage.tsx
@@ -4,11 +4,9 @@ import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Input, Pager, Select } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
-import { KEYS } from "../data/mock/keys";
import type { Role } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { memberRoleSummary, roleLabel } from "../lib/memberRole";
-import { readJson, writeJson } from "../lib/storage";
import type { MemberAdminView } from "../lib/tenantUsers";
import { useFormatApiError } from "../lib/apiErrors";
import { checkPasswordPolicy, isPasswordPolicyOk } from "../lib/passwordPolicy";
@@ -17,7 +15,7 @@ import { PLANS, type PlanId, type UsageMemberPrefs } from "../lib/usageMeter";
const PAGE_SIZE_OPTIONS = [5, 10, 20];
-/** 最近揭示的臨時密碼:可重整,需手動關閉 */
+/** 最近揭示的臨時密碼只保留在記憶體,不寫入瀏覽器儲存空間。 */
type RevealedTempPassword = {
uid: string;
display_name: string;
@@ -27,14 +25,6 @@ type RevealedTempPassword = {
reason?: "create" | "reset";
};
-function loadRevealedTemp(): RevealedTempPassword | null {
- return readJson(KEYS.adminTempPassword, null);
-}
-
-function saveRevealedTemp(rec: RevealedTempPassword | null): void {
- writeJson(KEYS.adminTempPassword, rec);
-}
-
/**
* 管理員:島民列表(搜尋名稱/uid)+ 新增、停權、權限、驗證、重設密碼。
*/
@@ -44,7 +34,6 @@ export function AdminUsersPage() {
const { t } = useI18n();
const formatApiError = useFormatApiError();
const { tick, refresh } = useData();
- const isLive = true;
const [users, setUsers] = useState([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -56,7 +45,7 @@ export function AdminUsersPage() {
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
- const [revealed, setRevealed] = useState(() => loadRevealedTemp());
+ const [revealed, setRevealed] = useState(null);
const [copyHint, setCopyHint] = useState("");
const [customPw, setCustomPw] = useState("");
@@ -87,7 +76,7 @@ export function AdminUsersPage() {
setError(formatApiError(e, "admin.users.loadFail"));
}
})();
- }, [repos.adminUsers, tick, isAdmin, page, pageSize, query]);
+ }, [repos.adminUsers, tick, isAdmin, page, pageSize, query, formatApiError]);
const selectedUid = selected?.uid ?? "";
const selectedRolesKey = selected?.roles?.slice().sort().join(",") ?? "";
@@ -99,12 +88,12 @@ export function AdminUsersPage() {
}, [selectedUid, selectedRolesKey]);
useEffect(() => {
- if (!selectedUid || !isAdmin || isLive) {
+ if (!selectedUid || !isAdmin) {
setUsagePrefs(null);
return;
}
void repos.usage.getMemberPrefs(selectedUid).then(setUsagePrefs).catch(() => setUsagePrefs(null));
- }, [selectedUid, isAdmin, isLive, repos.usage, tick]);
+ }, [selectedUid, isAdmin, repos.usage, tick]);
// 路由已包 RequireAdmin;此處僅防守
if (!member) return ;
@@ -152,7 +141,6 @@ export function AdminUsersPage() {
created_at: nowUnixNano(),
reason: "create",
};
- saveRevealedTemp(rec);
setRevealed(rec);
setCreateName("");
setCreateEmail("");
@@ -315,7 +303,6 @@ export function AdminUsersPage() {
created_at: nowUnixNano(),
reason: "reset",
};
- saveRevealedTemp(rec);
setRevealed(rec);
setCustomPw("");
setMessage(t("admin.users.resetDone", { name: res.user.display_name }));
@@ -357,7 +344,6 @@ export function AdminUsersPage() {
) {
return;
}
- saveRevealedTemp(null);
setRevealed(null);
setCopyHint("");
}
@@ -450,6 +436,7 @@ export function AdminUsersPage() {
/>
setCreatePassword(e.target.value)}
placeholder={t("admin.users.initPasswordPh")}
@@ -695,11 +682,7 @@ export function AdminUsersPage() {
{t("admin.users.usageTitle")}
- {isLive ? (
-
- {t("admin.users.usageLiveSkip")}
-
- ) : usagePrefs ? (
+ {usagePrefs ? (
<>
+
void refreshAccount(acc)}
+ >
+ {busy === `refresh:${acc.id}` ? "…" : t("crew.refreshSession")}
+
{t("forgot.nextStep")}
-
- {t("forgot.openReset")}
-
+ {t("forgot.openReset")}
-
-
- {t("forgot.back")}
-
-
+ {t("forgot.back")}
- {t("common.back")}
-
+ {t("common.back")}
}
/>
);
@@ -170,10 +168,8 @@ export function JobDetailPage() {
) : null}
{job.template_type === "compose_mimic" ? (
-
-
- {t("jobs.backCompose")}
-
+
+ {t("jobs.backCompose")}
) : null}
{canDeleteJob(job) ? (
@@ -186,11 +182,7 @@ export function JobDetailPage() {
{deleting ? t("common.loading") : t("jobs.delete")}
) : null}
-
-
- {t("jobs.backList")}
-
-
+ {t("jobs.backList")}
diff --git a/apps/web/src/pages/JobsPage.tsx b/apps/web/src/pages/JobsPage.tsx
index ce8202f..84db626 100644
--- a/apps/web/src/pages/JobsPage.tsx
+++ b/apps/web/src/pages/JobsPage.tsx
@@ -226,10 +226,8 @@ export function JobsPage() {
{t("jobs.template.tokenRenewBadge")}
) : null}
{jobStatusLabel(job.status, t)}
-
-
- {t("jobs.detail")}
-
+
+ {t("jobs.detail")}
{canDeleteJob(job) ? (
void removeBundle()}>
{busy ? t("outbox.detail.processing") : t("outbox.detail.delete")}
-
-
- {t("outbox.detail.back")}
-
+
+ {t("outbox.detail.back")}
{allPublished ? (
diff --git a/apps/web/src/pages/OutboxPage.tsx b/apps/web/src/pages/OutboxPage.tsx
index 5f0cf92..a8bb872 100644
--- a/apps/web/src/pages/OutboxPage.tsx
+++ b/apps/web/src/pages/OutboxPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Pager } from "../components/ui";
@@ -46,6 +46,7 @@ export function OutboxPage() {
const [page, setPage] = useState(1);
const [busyId, setBusyId] = useState("");
const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
function statusLabel(status: OutboxBundleStatus): string {
switch (status) {
@@ -64,9 +65,18 @@ export function OutboxPage() {
}
}
+ const load = useCallback(async () => {
+ try {
+ setItems(await repos.outbox.list());
+ setError("");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : t("outbox.loadFail"));
+ }
+ }, [repos.outbox, t]);
+
useEffect(() => {
- void repos.outbox.list().then(setItems);
- }, [repos.outbox, tick]);
+ void load();
+ }, [load, tick]);
useEffect(() => {
setPage(1);
@@ -84,6 +94,16 @@ export function OutboxPage() {
[items],
);
+ const hasInFlightItem = items.some(
+ (item) => item.status === "scheduling" || item.status === "active",
+ );
+
+ useEffect(() => {
+ if (!hasInFlightItem) return;
+ const timer = window.setInterval(() => void load(), 2000);
+ return () => window.clearInterval(timer);
+ }, [hasInFlightItem, load]);
+
const tabItems = tab === "active" ? activeItems : historyItems;
const shown = useMemo(
() => pageSlice(tabItems, page, OUTBOX_PAGE),
@@ -94,13 +114,14 @@ export function OutboxPage() {
if (!window.confirm(t("outbox.confirmDelete", { title: item.title }))) return;
setBusyId(item.id);
setMessage("");
+ setError("");
try {
await repos.outbox.remove(item.id);
- setItems(await repos.outbox.list());
+ await load();
setMessage(t("outbox.deleted", { title: item.title }));
refresh();
} catch (e) {
- setMessage(e instanceof Error ? e.message : t("outbox.deleteFail"));
+ setError(e instanceof Error ? e.message : t("outbox.deleteFail"));
} finally {
setBusyId("");
}
@@ -114,6 +135,11 @@ export function OutboxPage() {
{message}
) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
]/.test(value) && value !== "checkout_id") return value;
+ return sessionStorage.getItem(PENDING_CHECKOUT_KEY) ?? "";
}
-/**
- * 結帳:訂單摘要 + 權益 + 一鍵完成(mock 付款與生效合一)。
- */
export function PlanCheckoutPage() {
const repos = useRepos();
const { refresh } = useData();
- const { member } = useAuth();
const { t, formatPlanPrice } = useI18n();
const navigate = useNavigate();
const [params] = useSearchParams();
+ const result = params.get("result");
const planParam = params.get("plan");
-
- const planId: PlanId | null = isPlanId(planParam) ? planParam : null;
+ const planId = isPlanId(planParam) ? planParam : null;
const plan = planId ? PLANS[planId] : null;
const rights = planId ? getPlanRights(planId, t, formatPlanPrice) : null;
- const [currentId, setCurrentId] = useState("free");
- const [cardName, setCardName] = useState(member?.display_name || "");
- const [cardLast4, setCardLast4] = useState("4242");
+ const [currentId, setCurrentId] = useState(null);
+ const [loadingError, setLoadingError] = useState("");
const [busy, setBusy] = useState(false);
+ const [redirecting, setRedirecting] = useState(false);
const [error, setError] = useState("");
+ const [pollResult, setPollResult] = useState(null);
+ const [lastCheckout, setLastCheckout] = useState(null);
+ const [pollAttempt, setPollAttempt] = useState(0);
+
+ const checkoutId = result === "success" ? checkoutIdFromReturn(params.get("checkout_id")) : "";
useEffect(() => {
- void repos.usage.getPlanId().then(setCurrentId);
- }, [repos.usage]);
+ if (result) return;
+ void repos.usage
+ .getBillingSubscription()
+ .then((subscription) => {
+ setCurrentId(subscription.plan_id);
+ setLoadingError("");
+ })
+ .catch((e: unknown) => setLoadingError(billingErrorMessage(e, t, "plans.loadFail")));
+ }, [repos.usage, result, t]);
- const free = plan?.price_twd === 0;
- const already = planId != null && currentId === planId;
+ useEffect(() => {
+ if (result !== "success" || !checkoutId) return;
+ let stopped = false;
+ setBusy(true);
+ setError("");
+ setPollResult(null);
+ void pollCheckout(() => repos.usage.getCheckoutSession(checkoutId), {
+ stopped: () => stopped,
+ })
+ .then((outcome) => {
+ if (!outcome || stopped) return;
+ setLastCheckout(outcome.checkout);
+ setPollResult(outcome);
+ if (outcome.outcome === "fulfilled") {
+ sessionStorage.removeItem(PENDING_CHECKOUT_KEY);
+ refresh();
+ navigate("/app/usage", {
+ replace: true,
+ state: { purchaseOk: outcome.checkout.plan_id },
+ });
+ }
+ })
+ .catch((e: unknown) => {
+ if (!stopped) setError(billingErrorMessage(e, t, "checkout.pollFail"));
+ })
+ .finally(() => {
+ if (!stopped) setBusy(false);
+ });
+ return () => {
+ stopped = true;
+ };
+ }, [checkoutId, navigate, pollAttempt, refresh, repos.usage, result, t]);
- if (!member) return ;
- if (!plan || !planId || !rights) {
+ async function startCheckout() {
+ if (!planId || !currentId || (planId === "free" && currentId === "free")) return;
+ setBusy(true);
+ setRedirecting(false);
+ setError("");
+ try {
+ if (planId === "free" || planId === currentId) {
+ const portal = await repos.usage.createBillingPortalSession();
+ if (!isSafeBillingUrl(portal.url)) {
+ setError(t("checkout.invalidUrl"));
+ setBusy(false);
+ return;
+ }
+ beginRedirect(portal.url);
+ return;
+ }
+ const checkout = await repos.usage.createCheckoutSession(planId, crypto.randomUUID());
+ if (!checkout.id || !isSafeBillingUrl(checkout.url)) {
+ setError(t("checkout.invalidUrl"));
+ setBusy(false);
+ return;
+ }
+ sessionStorage.setItem(PENDING_CHECKOUT_KEY, checkout.id);
+ beginRedirect(checkout.url);
+ } catch (e) {
+ setError(billingErrorMessage(e, t, "checkout.fail"));
+ setBusy(false);
+ }
+ }
+
+ function beginRedirect(url: string) {
+ setRedirecting(true);
+ startBillingRedirect(
+ () => window.location.assign(url),
+ () => {
+ setRedirecting(false);
+ setBusy(false);
+ setError(t("checkout.redirectFailed"));
+ },
+ );
+ }
+
+ function retryPoll() {
+ setPollAttempt((attempt) => attempt + 1);
+ }
+
+ if (result === "cancel") {
return (
<>
-
- {t("checkout.pickFirst")}
-
-
-
-
{t("checkout.viewPlans")}
-
+
{t("checkout.canceledTitle")}
+
{t("checkout.canceledBody")}
+
+ {t("checkout.viewPlans")}
+
+
+ >
+ );
+ }
+
+ if (result === "success") {
+ const status = lastCheckout
+ ? `${lastCheckout.checkout_status} / ${lastCheckout.payment_status} / ${lastCheckout.fulfillment_status}`
+ : "";
+ return (
+ <>
+
+
+ {!checkoutId ? {t("checkout.missingId")}
: null}
+ {busy ? {t("checkout.verifying")}
: null}
+ {error ? {error}
: null}
+ {pollResult?.outcome === "failed" ? (
+ {t("checkout.terminalFail", { status })}
+ ) : null}
+ {pollResult?.outcome === "timeout" ? (
+ {t("checkout.timeout")}
+ ) : null}
+ {(error || pollResult) && checkoutId ? (
+ {t("checkout.retry")}
+ ) : null}
+
+ {t("plans.usageLink")}
>
);
}
- async function submit() {
- if (!planId || already) return;
- setError("");
- if (!free) {
- const digits = cardLast4.replace(/\D/g, "");
- if (digits.length < 4) {
- setError(t("checkout.cardLast4Required"));
- return;
- }
- if (!cardName.trim()) {
- setError(t("checkout.cardNameRequired"));
- return;
- }
- }
- setBusy(true);
- try {
- await repos.usage.purchasePlan(planId, {
- mock_ref: free ? "free" : `****${cardLast4.replace(/\D/g, "").slice(-4)}`,
- });
- // 強制重載 repos/頂欄用量(PLANS 配給依新 plan_id)
- refresh();
- navigate("/app/usage", {
- replace: true,
- state: { purchaseOk: planId },
- });
- } catch (e) {
- setError(e instanceof Error ? e.message : t("checkout.fail"));
- } finally {
- setBusy(false);
- }
+ if (!plan || !planId || !rights) {
+ return (
+ <>
+
+
+ {t("checkout.pickFirst")}
+ {t("checkout.viewPlans")}
+
+ >
+ );
}
- const actionLabel = free
- ? t("checkout.confirmFree")
+ if (!currentId) {
+ return (
+ <>
+
+ {loadingError ?
{loadingError}
:
{t("common.loading")}
}
+ >
+ );
+ }
+
+ const already = currentId === planId;
+ const currentFree = planId === "free" && currentId === "free";
+ const portalAction = (planId === "free" && currentId !== "free") || (already && currentId !== "free");
+ const actionLabel = portalAction
+ ? t("plans.manage")
: t("checkout.payAndAction", {
action: planCtaLabel(currentId, planId, t),
price: formatPlanPrice(plan.price_twd),
@@ -102,13 +216,8 @@ export function PlanCheckoutPage() {
return (
<>
-
- {error ? (
-
- {error}
-
- ) : null}
-
+ {error ?
{error}
: null}
+ {redirecting ?
{t("checkout.redirecting")}
: null}
@@ -116,105 +225,29 @@ export function PlanCheckoutPage() {
{t("checkout.subscribe")}
{plan.name}
-
- {rights.headline}
-
+
{rights.headline}
-
- {formatPlanPrice(plan.price_twd)}
- {t("checkout.perMonth")}
-
-
- {t("checkout.monthlyCredits", { n: plan.monthly_credits })}
-
-
- {rights.approxCallsLine}
-
+
{formatPlanPrice(plan.price_twd)}{t("checkout.perMonth")}
+
{t("checkout.monthlyCredits", { n: plan.monthly_credits })}
-
-
- {t("checkout.youGet")}
-
- {rights.rights.map((line) => (
- - {line}
- ))}
-
-
-
- {t("checkout.quota")}
-
- {rights.quota.map((line) => (
- - {line}
- ))}
-
-
-
- {t("checkout.notes")}
-
- {rights.notes.map((line) => (
- - {line}
- ))}
-
-
+
{t("checkout.youGet")}
{rights.rights.map((line) => - {line}
)}
+
{t("checkout.quota")}
{rights.quota.map((line) => - {line}
)}
+
{t("checkout.notes")}
{rights.notes.map((line) => - {line}
)}
-
- {!free && !already ? (
-
-
- setCardName(e.target.value)}
- autoComplete="cc-name"
- disabled={busy}
- />
- setCardLast4(e.target.value.replace(/\D/g, "").slice(0, 4))}
- inputMode="numeric"
- maxLength={4}
- placeholder="4242"
- disabled={busy}
- />
-
-
- ) : null}
-
diff --git a/apps/web/src/pages/PlansPage.tsx b/apps/web/src/pages/PlansPage.tsx
index 5955632..12e56c1 100644
--- a/apps/web/src/pages/PlansPage.tsx
+++ b/apps/web/src/pages/PlansPage.tsx
@@ -5,6 +5,7 @@ import { PageHeader } from "../components/layout/PageHeader";
import { PlanPricingGrid } from "../components/usage/PlanPricingGrid";
import { useRepos } from "../data/DataContext";
import { useI18n } from "../i18n/I18nContext";
+import { billingErrorMessage, isSafeBillingUrl, startBillingRedirect } from "../lib/billingCheckout";
import { getPlanRights } from "../lib/planRights";
import type { PlanId } from "../lib/usageMeter";
import { PLANS } from "../lib/usageMeter";
@@ -18,20 +19,79 @@ export function PlansPage() {
const { t, formatPlanPrice } = useI18n();
const navigate = useNavigate();
const [currentId, setCurrentId] = useState(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [redirecting, setRedirecting] = useState(false);
useEffect(() => {
- void repos.usage.getPlanId().then(setCurrentId);
- }, [repos.usage]);
+ void repos.usage
+ .getBillingSubscription()
+ .then((subscription) => {
+ setCurrentId(subscription.plan_id);
+ setError("");
+ })
+ .catch((e: unknown) => setError(billingErrorMessage(e, t, "plans.loadFail")));
+ }, [repos.usage, t]);
if (!member) return null;
- const current = currentId ?? "free";
+ async function openPortal() {
+ setBusy(true);
+ setError("");
+ try {
+ const portal = await repos.usage.createBillingPortalSession();
+ if (!isSafeBillingUrl(portal.url)) {
+ setError(t("checkout.invalidUrl"));
+ setBusy(false);
+ return;
+ }
+ setRedirecting(true);
+ startBillingRedirect(
+ () => window.location.assign(portal.url),
+ () => {
+ setRedirecting(false);
+ setBusy(false);
+ setError(t("checkout.redirectFailed"));
+ },
+ );
+ } catch (e) {
+ setError(billingErrorMessage(e, t, "checkout.fail"));
+ setBusy(false);
+ }
+ }
+
+ function choosePlan(id: PlanId) {
+ if (!currentId) return;
+ if ((id === "free" && currentId !== "free") || (id === currentId && id !== "free")) {
+ void openPortal();
+ return;
+ }
+ if (id !== currentId) navigate(`/app/usage/checkout?plan=${id}`);
+ }
+
+ if (!currentId) {
+ return (
+ <>
+
+ {error ? (
+ {error}
+ ) : (
+ {t("common.loading")}
+ )}
+ >
+ );
+ }
+
+ const current = currentId;
const currentRights = getPlanRights(current, t, formatPlanPrice);
return (
<>
+ {error ? {error}
: null}
+ {redirecting ? {t("checkout.redirectingPortal")}
: null}
+
{t("plans.current")}
@@ -51,8 +111,9 @@ export function PlansPage() {
navigate(`/app/usage/checkout?plan=${id}`)}
+ onChoose={choosePlan}
/>
>
diff --git a/apps/web/src/pages/ScoutPage.tsx b/apps/web/src/pages/ScoutPage.tsx
index 71eccba..0e93f44 100644
--- a/apps/web/src/pages/ScoutPage.tsx
+++ b/apps/web/src/pages/ScoutPage.tsx
@@ -22,6 +22,7 @@ import {
formatResearchLink,
} from "../lib/mockResearch";
import { newId } from "../lib/id";
+import { allowHttpUrl } from "../lib/externalUrl";
import { bumpScoutTodayDone, loadScoutToday, saveScoutToday } from "../lib/scoutToday";
import { nowUnixNano } from "../lib/time";
import { useI18n } from "../i18n/I18nContext";
@@ -149,6 +150,7 @@ function KnowledgeNoteCard({
const relationLabel = note.relation ? t(`scout.relation.${note.relation}`) : null;
const showPoints = compact ? points.slice(0, 3) : points;
const showHooks = compact ? hooks.slice(0, 1) : hooks;
+ const safeUrl = allowHttpUrl(note.url);
return (
@@ -185,16 +187,18 @@ function KnowledgeNoteCard({
) : null}
-
- {link.label}
- {link.host}
-
+ {safeUrl ? (
+
+ {link.label}
+ {link.host}
+
+ ) : null}
);
}
@@ -262,7 +266,7 @@ export function ScoutPage() {
setPosts(postList);
const usable = acc.filter((a) => a.is_usable);
setAccounts(usable);
- if (!accountId && usable[0]) setAccountId(usable[0].id);
+ setAccountId((current) => current || usable[0]?.id || "");
const pending = postList.filter(isPending).sort((a, b) => (b.score || 0) - (a.score || 0));
if (pending[0]) setActiveRunKey((cur) => cur || postRunKey(pending[0]!));
@@ -390,17 +394,20 @@ export function ScoutPage() {
}
});
+ const scanJobId = scanJob?.id;
+ const scanJobStatus = scanJob?.status;
+
useEffect(() => {
- if (!scanJob || ["succeeded", "failed", "cancelled"].includes(scanJob.status)) return;
+ if (!scanJobId || !scanJobStatus || ["succeeded", "failed", "cancelled"].includes(scanJobStatus)) return;
let cancelled = false;
- const poll = () => pollScanJob(scanJob.id, () => cancelled);
+ const poll = () => pollScanJob(scanJobId, () => cancelled);
void poll();
const id = window.setInterval(() => void poll(), 1500);
return () => {
cancelled = true;
window.clearInterval(id);
};
- }, [scanJob?.id, scanJob?.status]);
+ }, [scanJobId, scanJobStatus]);
// 選取貼文只帶入草稿;批次由使用者明確選取,不能反向切換。
useEffect(() => {
@@ -836,12 +843,12 @@ export function ScoutPage() {
{current.text}
- {current.created_at || current.permalink ? (
+ {current.created_at || allowHttpUrl(current.permalink) ? (
{current.created_at ? t("scout.createdAt", { time: formatLocalDateTime(current.created_at) }) : ""}
- {current.created_at && current.permalink ? " · " : ""}
- {current.permalink ? (
-
+ {current.created_at && allowHttpUrl(current.permalink) ? " · " : ""}
+ {allowHttpUrl(current.permalink) ? (
+
{t("scout.openPermalink")}
) : null}
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx
index db06b99..3e8a693 100644
--- a/apps/web/src/pages/SettingsPage.tsx
+++ b/apps/web/src/pages/SettingsPage.tsx
@@ -149,6 +149,28 @@ export function SettingsPage() {
}
}
+ async function clearAiKey() {
+ if (!ai) return;
+ setBusy("ai-clear");
+ setMessage("");
+ setError("");
+ try {
+ const next = await repos.settings.saveAi({
+ provider: normalizeProvider(ai.provider),
+ api_key_configured: false,
+ research_api_key_configured: false,
+ });
+ setAi({ ...ai, ...next });
+ setApiKey("");
+ setMessage(t("settings.aiKeyCleared"));
+ refresh();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : t("common.error"));
+ } finally {
+ setBusy("");
+ }
+ }
+
async function savePlacement() {
if (!placement) return;
setBusy("placement");
@@ -169,6 +191,24 @@ export function SettingsPage() {
}
}
+ async function clearExaKey() {
+ if (!placement) return;
+ setBusy("placement-clear");
+ setMessage("");
+ setError("");
+ try {
+ const next = await repos.settings.savePlacement({ exa_api_key_configured: false });
+ setPlacement(next);
+ setExaKey("");
+ setMessage(t("settings.exaKeyCleared"));
+ refresh();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : t("common.error"));
+ } finally {
+ setBusy("");
+ }
+ }
+
function saveLocaleCurrency() {
setPrefs({ locale: draftLocale, currency: draftCurrency });
setMessage(t("settings.localeSaved"));
@@ -315,9 +355,19 @@ export function SettingsPage() {
{t("settings.modelsCached")}
) : null}
- void saveAi()} disabled={busy === "ai"}>
- {t("common.save")}
-
+
+ void saveAi()} disabled={Boolean(busy)}>
+ {t("common.save")}
+
+ void clearAiKey()}
+ disabled={Boolean(busy) || (!ai.api_key_configured && !ai.research_api_key_configured)}
+ >
+ {busy === "ai-clear" ? t("common.loading") : t("settings.clearAiKey")}
+
+
) : (
{t("common.loading")}
@@ -378,9 +428,19 @@ export function SettingsPage() {
}}
/>
) : null}
- void savePlacement()} disabled={busy === "placement"}>
- {t("common.save")}
-
+
+ void savePlacement()} disabled={Boolean(busy)}>
+ {t("common.save")}
+
+ void clearExaKey()}
+ disabled={Boolean(busy) || !placement.exa_api_key_configured}
+ >
+ {busy === "placement-clear" ? t("common.loading") : t("settings.clearExaKey")}
+
+
) : (
{t("common.loading")}
diff --git a/apps/web/src/pages/TodayPage.tsx b/apps/web/src/pages/TodayPage.tsx
index 0c62367..3a97320 100644
--- a/apps/web/src/pages/TodayPage.tsx
+++ b/apps/web/src/pages/TodayPage.tsx
@@ -60,15 +60,11 @@ export function TodayPage() {
const dateLocale = locale === "en" ? "en-US" : "zh-TW";
- const todayLabel = useMemo(
- () =>
- new Date().toLocaleDateString(dateLocale, {
- month: "numeric",
- day: "numeric",
- weekday: "short",
- }),
- [dateLocale, tick],
- );
+ const todayLabel = new Date().toLocaleDateString(dateLocale, {
+ month: "numeric",
+ day: "numeric",
+ weekday: "short",
+ });
const load = useCallback(async () => {
setLoading(true);
@@ -94,8 +90,14 @@ export function TodayPage() {
.slice(0, 12),
);
- // 今日已回:localStorage + 後端 published 取較大
- const publishedN = posts.filter((p) => p.outreach_status === "published").length;
+ // 舊資料沒有發佈時間時不可拿歷史 published 總數冒充今日完成量。
+ const dayStart = startOfLocalDayNano();
+ const publishedN = posts.filter(
+ (p) =>
+ p.outreach_status === "published" &&
+ p.published_at != null &&
+ p.published_at >= dayStart,
+ ).length;
setScoutDone(Math.max(scoutLocal.done, publishedN));
// 自己貼文:全帳或分帳合併
@@ -144,7 +146,7 @@ export function TodayPage() {
return () => window.removeEventListener("harbor:store", onStore);
}, [refresh]);
- const dayStart = useMemo(() => startOfLocalDayNano(), [tick]);
+ const dayStart = startOfLocalDayNano();
const pendingScout = useMemo(
() =>
diff --git a/apps/web/src/pages/UsagePage.tsx b/apps/web/src/pages/UsagePage.tsx
index 3b33b03..95dc941 100644
--- a/apps/web/src/pages/UsagePage.tsx
+++ b/apps/web/src/pages/UsagePage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
@@ -80,9 +80,14 @@ export function UsagePage() {
to: todayKey(),
}));
- async function loadMine() {
- setSummary(await repos.usage.getSummary());
- }
+ const loadMine = useCallback(async () => {
+ try {
+ setSummary(await repos.usage.getSummary());
+ setError("");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : t("usage.fail"));
+ }
+ }, [repos.usage, t]);
async function loadTenant(query = applied) {
if (!isAdmin) return;
@@ -108,7 +113,7 @@ export function UsagePage() {
useEffect(() => {
void loadMine();
- }, [repos.usage, tick]);
+ }, [loadMine, tick]);
useEffect(() => {
if (tab === "tenant" && isAdmin) void loadTenant(applied);
@@ -192,7 +197,11 @@ export function UsagePage() {
return (
<>
- {t("common.loading")}
+ {error ? (
+ {error}
+ ) : (
+ {t("common.loading")}
+ )}
>
);
}
@@ -328,6 +337,26 @@ export function UsagePage() {
+
+
+
+
{t("usage.byok.title")}
+
{t("usage.byok.hint")}
+
+
{t("usage.meter.timesShort", { n: summary.byok.call_count })}
+
+
+ {(Object.keys(METER_META) as UsageMeter[])
+ .sort((a, b) => METER_META[a].order - METER_META[b].order)
+ .map((meter) => (
+ -
+ {t(`usage.meter.${meter}`)}
+ {t("usage.meter.timesShort", { n: summary.byok.by_meter[meter].count })}
+
+ ))}
+
+
+
([]);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
/** stream 中的助手暫存字(尚未 done) */
const [streamingText, setStreamingText] = useState("");
const [showAdd, setShowAdd] = useState(false);
@@ -169,21 +170,26 @@ export function InspirePanel({ accountId, personaId }: Props) {
useEffect(() => {
void (async () => {
- const [sess, els, bList, tList, summaries] = await Promise.all([
- repos.inspiration.getSession(),
- repos.inspiration.listElements(),
- repos.scout.listBrands(),
- repos.inspiration.listTrends("threads_tag"),
- repos.inspiration.listSessions().catch(() => [] as InspireSessionSummary[]),
- ]);
- const cleanEls = els.filter((e) => !isPersonaElement(e));
- applySessionLocal(sess, els);
- setSessionList(summaries);
- setElements(cleanEls);
- setBrands(bList);
- setTrends([...tList].sort((a, b) => a.heat - b.heat || 0).slice(0, 12));
+ try {
+ const [sess, els, bList, tList, summaries] = await Promise.all([
+ repos.inspiration.getSession(),
+ repos.inspiration.listElements(),
+ repos.scout.listBrands(),
+ repos.inspiration.listTrends("threads_tag"),
+ repos.inspiration.listSessions().catch(() => [] as InspireSessionSummary[]),
+ ]);
+ const cleanEls = els.filter((e) => !isPersonaElement(e));
+ applySessionLocal(sess, els);
+ setSessionList(summaries);
+ setElements(cleanEls);
+ setBrands(bList);
+ setTrends([...tList].sort((a, b) => a.heat - b.heat || 0).slice(0, 12));
+ setError("");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : t("inspire.fail"));
+ }
})();
- }, [repos, tick]);
+ }, [repos, t, tick]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -281,21 +287,19 @@ export function InspirePanel({ accountId, personaId }: Props) {
return map;
}, [elements]);
- async function persistPins(next: string[]) {
+ function setSessionPins(next: string[]) {
setPinnedIds(next);
if (!session) return;
- const saved = await repos.inspiration.saveSession({
- ...session,
- pinned_element_ids: next,
- });
- setSession(saved);
+ setSession({ ...session, pinned_element_ids: next });
+ setError("");
+ setMessage(t("inspire.pinsSessionLocal"));
}
function togglePin(id: string) {
const next = pinnedIds.includes(id)
? pinnedIds.filter((x) => x !== id)
: [...pinnedIds, id];
- void persistPins(next);
+ setSessionPins(next);
}
async function ensureBrandElement(b: Brand): Promise {
@@ -320,7 +324,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
async function pinBrand(b: Brand) {
const el = await ensureBrandElement(b);
- if (!pinnedIds.includes(el.id)) void persistPins([...pinnedIds, el.id]);
+ if (!pinnedIds.includes(el.id)) setSessionPins([...pinnedIds, el.id]);
}
function pinnedKeyOf(ids: string[]) {
@@ -569,7 +573,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
});
setElements((await repos.inspiration.listElements()).filter((e) => !isPersonaElement(e)));
}
- if (!pinnedIds.includes(el.id)) void persistPins([...pinnedIds, el.id]);
+ if (!pinnedIds.includes(el.id)) setSessionPins([...pinnedIds, el.id]);
setMessage(t("inspire.pinnedTrend", { label: item.label }));
}
@@ -683,8 +687,8 @@ export function InspirePanel({ accountId, personaId }: Props) {
i === 0 ? { ...s, account_id: accountId, text: body } : s,
);
}
- clearDraft();
- saveDraft(draft);
+ clearDraft("new");
+ saveDraft(draft, "new");
navigate("/app/studio/new");
}
@@ -968,6 +972,11 @@ export function InspirePanel({ accountId, personaId }: Props) {
{message}
) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
@@ -1064,6 +1073,7 @@ export function InspirePanel({ accountId, personaId }: Props) {
{t("inspire.pinnedCount", { n: pinnedElements.length })}
+
· {t("inspire.pinsLocalShort")}
{pinnedElements.map((el) => (
diff --git a/apps/web/src/pages/studio/MentionsPanel.tsx b/apps/web/src/pages/studio/MentionsPanel.tsx
index e7c38b1..0cfba89 100644
--- a/apps/web/src/pages/studio/MentionsPanel.tsx
+++ b/apps/web/src/pages/studio/MentionsPanel.tsx
@@ -5,6 +5,7 @@ import { Badge, Button, Card, EmptyState } from "../../components/ui";
import { useData, useRepos } from "../../data/DataContext";
import type { MentionItem, Persona, ThreadsAccount } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
+import { allowHttpUrl } from "../../lib/externalUrl";
import { isPersonaReady } from "../../lib/personaPrompt";
import { formatLocalDateTime } from "../../lib/time";
@@ -24,6 +25,7 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
const [composeOpen, setComposeOpen] = useState
>({});
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
useEffect(() => {
void repos.mentions.list(accountId || undefined).then(setItems);
@@ -48,13 +50,14 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
}
setBusy("sync");
setMessage("");
+ setError("");
try {
const list = await repos.mentions.sync(accountId);
setItems(list);
setMessage(t("mentions.syncDone", { n: list.length }));
refresh();
} catch (e) {
- setMessage(e instanceof Error ? e.message : t("mentions.syncFail"));
+ setError(e instanceof Error ? e.message : t("mentions.syncFail"));
} finally {
setBusy("");
}
@@ -68,12 +71,13 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
}
setBusy(id);
setMessage("");
+ setError("");
try {
const next = await repos.mentions.generateReply(id, sel.personaId);
setItems((list) => list.map((m) => (m.id === next.id ? next : m)));
setComposeOpen((m) => ({ ...m, [id]: true }));
} catch (e) {
- setMessage(e instanceof Error ? e.message : t("mentions.fail"));
+ setError(e instanceof Error ? e.message : t("mentions.fail"));
} finally {
setBusy("");
}
@@ -82,24 +86,29 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
async function reply(id: string, text?: string) {
setBusy(`s:${id}`);
setMessage("");
+ setError("");
try {
const next = await repos.mentions.markReplied(id, text);
setItems((list) => list.map((m) => (m.id === next.id ? next : m)));
- const acc = accounts.find((a) => a.id === getSel(id).accountId);
- setMessage(t("mentions.marked", { user: acc?.username || "—" }));
+ setMessage(t("mentions.marked"));
setComposeOpen((m) => ({ ...m, [id]: false }));
refresh();
} catch (e) {
- setMessage(e instanceof Error ? e.message : t("mentions.fail"));
+ setError(e instanceof Error ? e.message : t("mentions.fail"));
} finally {
setBusy("");
}
}
async function skip(id: string) {
- const next = await repos.mentions.skip(id);
- setItems((list) => list.map((m) => (m.id === next.id ? next : m)));
- refresh();
+ setError("");
+ try {
+ const next = await repos.mentions.skip(id);
+ setItems((list) => list.map((m) => (m.id === next.id ? next : m)));
+ refresh();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : t("mentions.fail"));
+ }
}
const pending = items.filter((m) => m.status === "pending").length;
@@ -126,6 +135,11 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
{message}
) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
{items.length === 0 ? (
{m.context_snippet}
- {m.permalink ? (
+ {allowHttpUrl(m.permalink) ? (
<>
{" · "}
-
+
{t("mentions.openThread")}
>
@@ -188,6 +202,9 @@ export function MentionsPanel({ accountId, personaId, accounts, personas }: Prop
generating={busy === m.id}
sending={busy === `s:${m.id}`}
label={t("mentions.draftLabel")}
+ showAccount={false}
+ actionLabel={t("mentions.markReplied")}
+ actionBusyLabel={t("mentions.markingReplied")}
/>
) : null}
{m.status === "replied" && m.draft_text ? (
diff --git a/apps/web/src/pages/studio/OwnPostsPanel.tsx b/apps/web/src/pages/studio/OwnPostsPanel.tsx
index 1c761a5..6ccc690 100644
--- a/apps/web/src/pages/studio/OwnPostsPanel.tsx
+++ b/apps/web/src/pages/studio/OwnPostsPanel.tsx
@@ -7,6 +7,7 @@ import { useData, useRepos } from "../../data/DataContext";
import type { OwnPost, OwnPostReply, Persona, ThreadsAccount } from "../../domain/types";
import { useI18n } from "../../i18n/I18nContext";
import { buildStructureNotes, saveComposeMimicBridge } from "../../lib/composeBridge";
+import { allowHttpUrl } from "../../lib/externalUrl";
import { isPersonaReady } from "../../lib/personaPrompt";
import { formatLocalDateTime } from "../../lib/time";
@@ -325,10 +326,10 @@ export function OwnPostsPanel({ accountId, personaId, accounts, personas }: Prop
{formatLocalDateTime(post.published_at)}
{post.shortcode ? ` · ${post.shortcode}` : ""}
- {post.permalink ? (
+ {allowHttpUrl(post.permalink) ? (
<>
{" · "}
-
+
{t("posts.openThreads")}
>
diff --git a/apps/web/src/pages/studio/PlaysPanel.tsx b/apps/web/src/pages/studio/PlaysPanel.tsx
index f67916c..ab19ace 100644
--- a/apps/web/src/pages/studio/PlaysPanel.tsx
+++ b/apps/web/src/pages/studio/PlaysPanel.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ImageAttach } from "../../components/studio/ImageAttach";
import { Badge, Button, Card, EmptyState, Input, Select, Textarea } from "../../components/ui";
@@ -15,6 +15,7 @@ import type {
ThreadsAccount,
} from "../../domain/types";
import type { AttachedImage } from "../../lib/attachImage";
+import { allowHttpUrl } from "../../lib/externalUrl";
import { newId } from "../../lib/id";
import { isPersonaReady, personaOptionLabel } from "../../lib/personaPrompt";
@@ -246,6 +247,8 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
}
}
+ const reloadSchemesAfterJob = useEffectEvent(reloadSchemes);
+
function switchMode(mode: TargetMode) {
setTargetMode(mode);
setEditing(null);
@@ -446,7 +449,7 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
if (fresh) {
setEditing(fresh);
setMessage(t("plays.scriptJobDone"));
- void reloadSchemes();
+ void reloadSchemesAfterJob();
refresh();
} else {
setMessage(t("plays.scriptJobDoneReload"));
@@ -694,10 +697,10 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
{selectedPost.text}
{formatLocalDateTime(selectedPost.published_at)}
- {selectedPost.permalink ? (
+ {allowHttpUrl(selectedPost.permalink) ? (
<>
{" · "}
-
+
{t("plays.openThreads")}
>
@@ -719,9 +722,11 @@ export function PlaysPanel({ accountId, personas: personasProp = [] }: Props) {
{external.text_preview}
-
- {external.url}
-
+ {allowHttpUrl(external.url) ? (
+
+ {external.url}
+
+ ) : null}
) : null}
diff --git a/apps/web/src/pages/studio/WizardPage.tsx b/apps/web/src/pages/studio/WizardPage.tsx
index dbf449e..bac6998 100644
--- a/apps/web/src/pages/studio/WizardPage.tsx
+++ b/apps/web/src/pages/studio/WizardPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { PageHeader } from "../../components/layout/PageHeader";
import { Stepper } from "../../components/studio/Stepper";
@@ -26,12 +26,15 @@ import { TopicStep } from "./steps/TopicStep";
export function WizardPage() {
const { playId } = useParams();
const isNew = !playId || playId === "new";
+ const draftScope = isNew ? "new" : playId;
const repos = useRepos();
const { refresh } = useData();
const navigate = useNavigate();
const { t } = useI18n();
- const [draft, setDraft] = useState
(() => loadDraft());
+ const [draft, setDraft] = useState(() => loadDraft(draftScope));
+ const scopeRef = useRef(draftScope);
+ const skipSaveRef = useRef(false);
const [accounts, setAccounts] = useState([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@@ -41,9 +44,14 @@ export function WizardPage() {
}, [repos.accounts]);
useEffect(() => {
+ if (scopeRef.current !== draftScope) {
+ scopeRef.current = draftScope;
+ skipSaveRef.current = true;
+ setDraft(loadDraft(draftScope));
+ }
if (!isNew && playId) {
void repos.plays.get(playId).then((play) => {
- if (!play) return;
+ if (!play || scopeRef.current !== draftScope) return;
setDraft({
playId: play.id,
stepIndex: 0,
@@ -58,13 +66,19 @@ export function WizardPage() {
Math.round((play.steps[1]?.delay_from_previous_sec || 300) / 60),
),
});
+ }).catch((e: unknown) => {
+ setError(e instanceof Error ? e.message : t("common.error"));
});
}
- }, [isNew, playId, repos.plays]);
+ }, [draftScope, isNew, playId, repos.plays, t]);
useEffect(() => {
- saveDraft(draft);
- }, [draft]);
+ if (skipSaveRef.current) {
+ skipSaveRef.current = false;
+ return;
+ }
+ saveDraft(draft, draftScope);
+ }, [draft, draftScope]);
const accountMap = useMemo(() => new Map(accounts.map((a) => [a.id, a])), [accounts]);
@@ -111,7 +125,7 @@ export function WizardPage() {
if (code) throw new Error(t(code));
await repos.plays.save(play);
const bundle = await repos.plays.submit(play.id);
- clearDraft();
+ clearDraft(draftScope);
setDraft(emptyDraft());
refresh();
navigate(`/app/outbox/${bundle.id}`);
diff --git a/apps/web/src/pages/studio/wizardState.test.ts b/apps/web/src/pages/studio/wizardState.test.ts
new file mode 100644
index 0000000..5180e41
--- /dev/null
+++ b/apps/web/src/pages/studio/wizardState.test.ts
@@ -0,0 +1,28 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { clearDraft, emptyDraft, loadDraft, saveDraft } from "./wizardState";
+
+describe("wizard draft scopes", () => {
+ beforeEach(() => localStorage.clear());
+
+ it("keeps new and existing play drafts isolated", () => {
+ const newDraft = { ...emptyDraft(), title: "New play" };
+ const existingDraft = { ...emptyDraft(), title: "Existing play", playId: "play-42" };
+
+ saveDraft(newDraft, "new");
+ saveDraft(existingDraft, "play-42");
+
+ expect(loadDraft("new")).toEqual(newDraft);
+ expect(loadDraft("play-42")).toEqual(existingDraft);
+ });
+
+ it("clears only the requested draft scope", () => {
+ const existingDraft = { ...emptyDraft(), title: "Keep me", playId: "play-42" };
+ saveDraft({ ...emptyDraft(), title: "Discard me" }, "new");
+ saveDraft(existingDraft, "play-42");
+
+ clearDraft("new");
+
+ expect(loadDraft("new")).toMatchObject({ title: "", stepIndex: 0 });
+ expect(loadDraft("play-42")).toEqual(existingDraft);
+ });
+});
diff --git a/apps/web/src/pages/studio/wizardState.ts b/apps/web/src/pages/studio/wizardState.ts
index 7c86d7f..ddb9a52 100644
--- a/apps/web/src/pages/studio/wizardState.ts
+++ b/apps/web/src/pages/studio/wizardState.ts
@@ -52,20 +52,24 @@ export function emptyDraft(): WizardDraft {
};
}
-export function loadDraft(): WizardDraft {
- const raw = readJson(KEYS.playDraft, null);
+function draftKey(scope: string): string {
+ return `${KEYS.playDraft}:${scope}`;
+}
+
+export function loadDraft(scope = "new"): WizardDraft {
+ const raw = readJson(draftKey(scope), null);
if (!raw || typeof raw !== "object" || !Array.isArray(raw.steps)) {
return emptyDraft();
}
return raw;
}
-export function saveDraft(draft: WizardDraft): void {
- writeJson(KEYS.playDraft, draft);
+export function saveDraft(draft: WizardDraft, scope = "new"): void {
+ writeJson(draftKey(scope), draft);
}
-export function clearDraft(): void {
- writeJson(KEYS.playDraft, emptyDraft());
+export function clearDraft(scope = "new"): void {
+ writeJson(draftKey(scope), emptyDraft());
}
export function applyIntervalToSteps(steps: PlayStep[], intervalMinutes: number): PlayStep[] {
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css
index af90326..dfee5d6 100644
--- a/apps/web/src/styles/global.css
+++ b/apps/web/src/styles/global.css
@@ -84,13 +84,9 @@ select {
color: inherit;
}
-/* 不要瀏覽器預設粗 outline/旁側條;鍵盤 focus 用元件自身樣式 */
-:focus {
- outline: none;
-}
-
:focus-visible {
- outline: none;
+ outline: 3px solid var(--hb-focus, #2672d3);
+ outline-offset: 3px;
}
img,
@@ -99,6 +95,28 @@ svg {
max-width: 100%;
}
+.hb-route-loading {
+ width: 2rem;
+ height: 2rem;
+ margin: 20vh auto;
+ border: 3px solid var(--hb-line);
+ border-top-color: var(--hb-brand);
+ border-radius: 50%;
+ animation: hb-route-spin 0.75s linear infinite;
+}
+
+@keyframes hb-route-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hb-route-loading {
+ animation: none;
+ }
+}
+
/* English decorative labels */
.display-en {
font-family: var(--hb-font-en);
@@ -3459,6 +3477,29 @@ svg {
color: color-mix(in srgb, #d4524a 85%, var(--hb-text));
}
+.hb-usage-byok-list {
+ list-style: none;
+ margin: 0.75rem 0 0;
+ padding: 0.75rem 0 0;
+ border-top: 1px solid var(--hb-line);
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.55rem 1.25rem;
+}
+
+.hb-usage-byok-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ font-size: var(--hb-text-sm);
+}
+
+@media (max-width: 560px) {
+ .hb-usage-byok-list {
+ grid-template-columns: 1fr;
+ }
+}
+
.hb-usage-ppick {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
diff --git a/apps/web/src/styles/ui.css b/apps/web/src/styles/ui.css
index ff67fa0..2a56831 100644
--- a/apps/web/src/styles/ui.css
+++ b/apps/web/src/styles/ui.css
@@ -432,7 +432,8 @@
}
.hb-banner-ok,
-.hb-banner-warn {
+.hb-banner-warn,
+.hb-banner-error {
margin: 0;
padding: 0.8rem 1rem;
border-radius: var(--hb-radius);
@@ -452,6 +453,12 @@
color: var(--hb-ink);
}
+.hb-banner-error {
+ border-color: color-mix(in srgb, var(--hb-danger) 40%, var(--hb-line));
+ background: color-mix(in srgb, var(--hb-danger) 10%, var(--hb-surface));
+ color: var(--hb-danger);
+}
+
.hb-check-row {
display: flex;
align-items: flex-start;
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
new file mode 100644
index 0000000..f149f27
--- /dev/null
+++ b/apps/web/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 0000000..69cf664
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/test/setup.ts"],
+ },
+});
diff --git a/deploy/prod/README.md b/deploy/prod/README.md
index bbf1424..6e159fd 100644
--- a/deploy/prod/README.md
+++ b/deploy/prod/README.md
@@ -35,6 +35,13 @@ sudoedit /etc/harbor/harbor.env
Restart the active services after changing runtime env values.
+The first release creates `admin@haixun.local` once. Read its generated initial
+password, sign in, then change it immediately:
+
+```bash
+ssh harbor-prod 'sudo cat /etc/harbor/initial-admin-credentials'
+```
+
## Normal Release
```bash
diff --git a/deploy/prod/build-release.sh b/deploy/prod/build-release.sh
index 9b8fc10..00a3c27 100755
--- a/deploy/prod/build-release.sh
+++ b/deploy/prod/build-release.sh
@@ -18,17 +18,22 @@ trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/bin" "$stage/web" "$stage/migrations" "$ARTIFACT_DIR"
if [[ ${SKIP_TESTS:-0} != 1 ]]; then
- (cd "$BACKEND_DIR" && go test ./... && go vet ./...)
- (cd "$WEB_DIR" && npm test)
+ (cd "$BACKEND_DIR" && go test ./... && go vet ./...)
+fi
+
+(cd "$WEB_DIR" && npm ci)
+if [[ ${SKIP_TESTS:-0} != 1 ]]; then
+ (cd "$WEB_DIR" && npm test)
fi
printf '%s\n' "building Linux amd64 gateway and worker"
(cd "$BACKEND_DIR" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$stage/bin/gateway" .)
(cd "$BACKEND_DIR" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$stage/bin/worker" ./cmd/worker)
+(cd "$BACKEND_DIR" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$stage/bin/seeder" ./cmd/seeder)
GOBIN="$stage/bin" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install -tags mongodb github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
printf '%s\n' "building static web"
-(cd "$WEB_DIR" && npm ci && VITE_API_BASE= npm run build)
+(cd "$WEB_DIR" && VITE_API_BASE= npm run build)
cp -a "$WEB_DIR/dist/." "$stage/web/"
cp -a "$BACKEND_DIR/generate/database/mongo/." "$stage/migrations/"
@@ -39,10 +44,10 @@ built_at=$timestamp
goos=linux
goarch=amd64
EOF
-(cd "$stage" && sha256sum bin/gateway bin/worker bin/migrate web/index.html > manifest.sha256)
+(cd "$stage" && sha256sum bin/gateway bin/worker bin/seeder bin/migrate web/index.html > manifest.sha256)
find "$stage" -type d -exec chmod 0755 {} +
find "$stage" -type f -exec chmod 0644 {} +
-chmod 0755 "$stage/bin/gateway" "$stage/bin/worker" "$stage/bin/migrate"
+chmod 0755 "$stage/bin/gateway" "$stage/bin/worker" "$stage/bin/seeder" "$stage/bin/migrate"
artifact="$ARTIFACT_DIR/$release_id.tar.gz"
tar -C "$stage" -czf "$artifact" .
diff --git a/deploy/prod/remote/activate-release.sh b/deploy/prod/remote/activate-release.sh
index 6e6bd24..c6d65ff 100755
--- a/deploy/prod/remote/activate-release.sh
+++ b/deploy/prod/remote/activate-release.sh
@@ -5,6 +5,11 @@ if [[ ${EUID} -ne 0 ]]; then
printf '%s\n' "activate-release must run as root" >&2
exit 1
fi
+exec 9>/run/lock/harbor-release.lock
+if ! flock -n 9; then
+ printf '%s\n' "another release or rollback is in progress" >&2
+ exit 1
+fi
archive=${1:-}
checksum=${2:-}
@@ -30,17 +35,23 @@ if [[ ! "$release_id" =~ ^harbor-[0-9]{8}T[0-9]{6}Z-[a-zA-Z0-9._-]+$ ]]; then
fi
release_dir=/opt/harbor/releases/$release_id
if [[ ! -d "$release_dir" ]]; then
- install -d -m 0755 "$release_dir"
- tar -xzf "$archive" -C "$release_dir"
+ tmp_release=$(mktemp -d "/opt/harbor/releases/.${release_id}.XXXXXX")
+ trap 'rm -rf "${tmp_release:-}"' EXIT
+ chown harbor:harbor "$tmp_release"
+ runuser -u harbor -- tar --no-same-owner --no-same-permissions -xzf "$archive" -C "$tmp_release"
+ runuser -u harbor -- sh -c 'cd "$1" && sha256sum -c manifest.sha256' sh "$tmp_release"
+ mv "$tmp_release" "$release_dir"
+ trap - EXIT
fi
test -x "$release_dir/bin/gateway"
test -x "$release_dir/bin/worker"
+test -x "$release_dir/bin/seeder"
test -x "$release_dir/bin/migrate"
test -f "$release_dir/web/index.html"
chown -R root:harbor "$release_dir"
find "$release_dir" -type d -exec chmod 0755 {} +
find "$release_dir" -type f -exec chmod 0644 {} +
-chmod 0755 "$release_dir/bin/gateway" "$release_dir/bin/worker" "$release_dir/bin/migrate"
+chmod 0755 "$release_dir/bin/gateway" "$release_dir/bin/worker" "$release_dir/bin/seeder" "$release_dir/bin/migrate"
active=blue
if [[ -f /etc/harbor/active-slot ]]; then
@@ -68,9 +79,9 @@ set -a
source /etc/harbor/harbor.env
set +a
printf '%s\n' "running forward migrations"
-"$release_dir/bin/migrate" -path "$release_dir/migrations" -database "$MONGO_URL" up || status=$?
-if [[ ${status:-0} -ne 0 && ${status:-0} -ne 2 ]]; then
- exit "$status"
+runuser -u harbor -- "$release_dir/bin/migrate" -path "$release_dir/migrations" -database "$MONGO_URL" up
+if [[ ! -f /etc/harbor/admin-seeded ]]; then
+ /opt/harbor/deploy/remote/seed-admin.sh "$release_dir/bin/seeder"
fi
ln -sfn "$release_dir" "/opt/harbor/slots/$inactive"
@@ -88,6 +99,14 @@ if ! curl -fsS "http://127.0.0.1:$inactive_port/api/v1/health" >/dev/null; then
exit 1
fi
+systemctl start "harbor-worker@$inactive.service"
+sleep 3
+if ! systemctl is-active --quiet "harbor-worker@$inactive.service"; then
+ journalctl -u "harbor-worker@$inactive.service" -n 80 --no-pager >&2
+ systemctl stop "harbor-gateway@$inactive.service"
+ exit 1
+fi
+
if [[ -L /opt/harbor/current ]]; then
readlink -f /opt/harbor/current > /etc/harbor/previous-release
printf '%s\n' "$active" > /etc/harbor/previous-slot
@@ -99,14 +118,8 @@ mv -f /etc/nginx/harbor-active-server.conf.new /etc/nginx/harbor-active-server.c
nginx -t
systemctl reload nginx
printf '%s\n' "$inactive" > /etc/harbor/active-slot
-
-systemctl start "harbor-worker@$inactive.service"
-sleep 3
-if ! systemctl is-active --quiet "harbor-worker@$inactive.service"; then
- journalctl -u "harbor-worker@$inactive.service" -n 80 --no-pager >&2
- /opt/harbor/deploy/remote/rollback.sh || true
- exit 1
-fi
+systemctl enable "harbor-gateway@$inactive.service" "harbor-worker@$inactive.service" >/dev/null
+systemctl disable "harbor-gateway@$active.service" "harbor-worker@$active.service" >/dev/null 2>&1 || true
systemctl stop --no-block "harbor-worker@$active.service" 2>/dev/null || true
curl -fsS http://127.0.0.1/health >/dev/null
diff --git a/deploy/prod/remote/bootstrap.sh b/deploy/prod/remote/bootstrap.sh
index 4ec1c9f..46bc8b0 100755
--- a/deploy/prod/remote/bootstrap.sh
+++ b/deploy/prod/remote/bootstrap.sh
@@ -127,8 +127,16 @@ PORT=8889
EOF
chmod 0644 /etc/harbor/gateway-blue.env /etc/harbor/gateway-green.env
-printf '%s\n' 'server 127.0.0.1:8888;' > /etc/nginx/harbor-active-server.conf
-install -m 0644 /opt/harbor/deploy/nginx/harbor-http.conf /etc/nginx/sites-available/harbor.conf
+active_slot=blue
+[[ -f /etc/harbor/active-slot ]] && active_slot=$(cat /etc/harbor/active-slot)
+active_port=8888
+[[ "$active_slot" == green ]] && active_port=8889
+printf 'server 127.0.0.1:%s;\n' "$active_port" > /etc/nginx/harbor-active-server.conf
+if [[ -f /etc/letsencrypt/live/threads-tool-dev.30cm.net/fullchain.pem ]]; then
+ install -m 0644 /opt/harbor/deploy/nginx/harbor-tls.conf /etc/nginx/sites-available/harbor.conf
+else
+ install -m 0644 /opt/harbor/deploy/nginx/harbor-http.conf /etc/nginx/sites-available/harbor.conf
+fi
ln -sfn /etc/nginx/sites-available/harbor.conf /etc/nginx/sites-enabled/harbor.conf
rm -f /etc/nginx/sites-enabled/default
@@ -142,7 +150,7 @@ visudo -cf /etc/sudoers.d/harbor-deploy
systemctl daemon-reload
docker compose --env-file /etc/harbor/harbor.env -f /opt/harbor/deploy/compose/docker-compose.yml pull
-docker compose --env-file /etc/harbor/harbor.env -f /opt/harbor/deploy/compose/docker-compose.yml up -d
+docker compose --env-file /etc/harbor/harbor.env -f /opt/harbor/deploy/compose/docker-compose.yml up -d --wait --wait-timeout 180
ufw allow OpenSSH
ufw allow 80/tcp
diff --git a/deploy/prod/remote/certbot-renew-hook.sh b/deploy/prod/remote/certbot-renew-hook.sh
new file mode 100755
index 0000000..613bbcd
--- /dev/null
+++ b/deploy/prod/remote/certbot-renew-hook.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+nginx -t
+systemctl reload nginx
diff --git a/deploy/prod/remote/enable-tls.sh b/deploy/prod/remote/enable-tls.sh
index 72a2991..aebea38 100755
--- a/deploy/prod/remote/enable-tls.sh
+++ b/deploy/prod/remote/enable-tls.sh
@@ -11,6 +11,8 @@ if [[ ! -f "/etc/letsencrypt/live/$domain/fullchain.pem" || ! -f "/etc/letsencry
exit 1
fi
install -m 0644 /opt/harbor/deploy/nginx/harbor-tls.conf /etc/nginx/sites-available/harbor.conf
+install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
+install -m 0755 /opt/harbor/deploy/remote/certbot-renew-hook.sh /etc/letsencrypt/renewal-hooks/deploy/harbor-nginx
nginx -t
systemctl reload nginx
printf '%s\n' "TLS enabled for $domain"
diff --git a/deploy/prod/remote/rollback.sh b/deploy/prod/remote/rollback.sh
index a940fa2..49c25f8 100755
--- a/deploy/prod/remote/rollback.sh
+++ b/deploy/prod/remote/rollback.sh
@@ -5,6 +5,11 @@ if [[ ${EUID} -ne 0 ]]; then
printf '%s\n' "rollback must run as root" >&2
exit 1
fi
+exec 9>/run/lock/harbor-release.lock
+if ! flock -n 9; then
+ printf '%s\n' "another release or rollback is in progress" >&2
+ exit 1
+fi
test -f /etc/harbor/previous-release
test -f /etc/harbor/previous-slot
@@ -24,6 +29,9 @@ for _ in $(seq 1 60); do
sleep 1
done
curl -fsS "http://127.0.0.1:$previous_port/api/v1/health" >/dev/null
+systemctl start "harbor-worker@$previous_slot.service"
+sleep 3
+systemctl is-active --quiet "harbor-worker@$previous_slot.service"
current_release=$(readlink -f /opt/harbor/current)
current_slot=$(cat /etc/harbor/active-slot)
@@ -36,7 +44,8 @@ mv -f /etc/nginx/harbor-active-server.conf.new /etc/nginx/harbor-active-server.c
nginx -t
systemctl reload nginx
printf '%s\n' "$previous_slot" > /etc/harbor/active-slot
-systemctl start "harbor-worker@$previous_slot.service"
+systemctl enable "harbor-gateway@$previous_slot.service" "harbor-worker@$previous_slot.service" >/dev/null
+systemctl disable "harbor-gateway@$current_slot.service" "harbor-worker@$current_slot.service" >/dev/null 2>&1 || true
systemctl stop --no-block "harbor-worker@$current_slot.service" 2>/dev/null || true
curl -fsS http://127.0.0.1/health >/dev/null
printf '%s\n' "rolled back to $(basename "$previous_release") on $previous_slot"
diff --git a/deploy/prod/remote/seed-admin.sh b/deploy/prod/remote/seed-admin.sh
new file mode 100755
index 0000000..fb94f7a
--- /dev/null
+++ b/deploy/prod/remote/seed-admin.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ ${EUID} -ne 0 ]]; then
+ printf '%s\n' "seed-admin must run as root" >&2
+ exit 1
+fi
+seeder=${1:-}
+test -x "$seeder"
+
+password=$(openssl rand -base64 24 | tr -d '/+=')
+set -a
+source /etc/harbor/harbor.env
+set +a
+runuser -u harbor -- env SEED_ADMIN_PASSWORD="$password" "$seeder" -f /etc/harbor/gateway.yaml
+
+credentials=/etc/harbor/initial-admin-credentials
+umask 077
+cat > "$credentials" </`(requirements → spec → plan → tasks)。
-- **流程 skill:** 專案內 `/spec-driven`(`.grok/skills/spec-driven/`)。未批准 plan/tasks 前不要開大範圍實作。
-- 現況架構導覽:`docs/architecture.md`。技術債備忘:`docs/improvement-notes.md`(不取代 product requirements)。
-
-## 核心原則
-
-- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**(`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload,不作為儲存時區。
-- 複製模式,不複製舊業務。
-- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。
-- provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。
-- setting 是通用 key-value model,不依賴 AI 或其他業務。
-- token / API key 第一版每次 request 帶入,不寫入 config。
-- SSE contract 由本服務 normalize,前端不要讀 provider 原始 chunk。
-- JSON API 必須使用 `code/message/data/error` envelope 與 `SSCCCDDD` 錯誤碼。
-- 列表 API 必須使用 `page/pageSize` query,並在 `data` 回傳 `pagination/list`。
-- Job 狀態轉移必須使用 guarded/conditional update;不要在 API/worker 直接裸 `Update` 覆蓋 job 狀態。
-- Redis job lock 的 value 是 `workerID`;release / refresh 必須檢查 owner,長任務必須 heartbeat。
-- Auth 目前是 native email/password + JWT,不包含 OAuth / OTP / MFA / Zitadel。不要為了相容 template-monorepo 把重依賴搬進來。
-- AI provider token 與會員 JWT 是兩種不同 token;AI token 每次 request header 帶入,會員 JWT 由 `/api/v1/auth/*` 簽發。
-
-## 設計文件
-
-- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。
-- `docs/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。
-- `docs/scan-placement-plan.md`:海巡獲客(流程 B)— 知識圖譜、Brave 擴展、雙軌爬取(7 天重點 / 30 天補充)、產品匹配、島民交接。
-
-## 新增 API 流程
-
-1. 修改 `generate/api/*.api`。
-2. 優先使用 `make gen-api` 重新產生 handler/logic/types。
-3. 若手寫 handler,仍需遵守 `response.Write` 與 validator 流程。
-4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`。
-5. 更新 `README.md` 的 API 與架構說明。
-
-## Response / Error Code
-
-錯誤碼格式是 `SSCCCDDD`:
-
-```text
-SS = scope
-CCC = category
-DDD = detail
-```
-
-目前 scope:
-
-```text
-10 = Facade
-32 = Setting
-33 = AI
-34 = Job
-35 = Auth
-36 = Member
-37 = Permission
-```
-
-建立錯誤時使用:
-
-```go
-errs.For(code.AI).InputMissingRequired("缺少 AI provider token")
-errs.For(code.Setting).ResNotFound("找不到設定")
-errs.For(code.Job).ResInvalidState("job state changed; update rejected")
-errs.For(code.Auth).AuthUnauthorized("missing bearer token")
-```
-
-不要直接手寫 `33104000` 這種數字,也不要回傳裸 `error` 給 handler 後讓使用者看到內部錯誤。
-
-## Pagination
-
-列表 API 使用:
-
-```text
-?page=1&pageSize=10
-```
-
-response:
-
-```json
-{
- "code": 102000,
- "message": "SUCCESS",
- "data": {
- "pagination": {
- "total": 100,
- "page": 1,
- "pageSize": 10,
- "totalPages": 10
- },
- "list": []
- }
-}
-```
-
-`page/pageSize` 必須是 server 正規化後的值。不要使用 `offset/limit/items`。
-
-## 新增 Model 流程
-
-模組放在:
-
-```text
-internal/model//
- domain/entity
- domain/repository
- domain/usecase
- repository
- usecase
-```
-
-依賴方向:
-
-```text
-handler -> logic -> model/domain/usecase
-model/usecase -> model/domain/repository
-model/repository -> Mongo / Redis
-```
-
-不要讓 `logic` import `model//repository`。
-
-## Auth / Permission 擴充
-
-目前已接:
-
-```text
-POST /api/v1/auth/register
-POST /api/v1/auth/login
-POST /api/v1/auth/refresh
-POST /api/v1/auth/logout # requires member JWT
-GET /api/v1/members/me
-PATCH /api/v1/members/me
-GET /api/v1/permissions/catalog
-GET /api/v1/permissions/me
-```
-
-Auth matrix(`internal/handler/routes.go`):
-
-| 路由 | 需要會員 JWT |
-|------|----------------|
-| `GET /api/v1/health` | 否 |
-| `POST /api/v1/auth/register/login/refresh` | 否 |
-| `POST /api/v1/auth/logout` | 是(`Authorization`) |
-| `GET /api/v1/ai/providers` | 否 |
-| `POST /api/v1/ai/chat/stream/models` | 是(`X-Member-Authorization`)+ provider token(`Authorization`) |
-| `/api/v1/members/*`、`/api/v1/permissions/me` | 是(`Authorization`) |
-| `/api/v1/permissions/catalog`、`/api/v1/settings/*`、`/api/v1/jobs*`、`/api/v1/job/*` | 是(`Authorization`) |
-
-規則:
-
-- 保護路由用 `internal/middleware.Auth`(`Authorization: Bearer `);AI 變更路由用 `middleware.MemberAuth`(`X-Member-Authorization`),因 `Authorization` 保留給 provider API key。
-- logic 從 `authctx.ActorFromContext` 讀 `tenant_id` / `uid`。
-- 不要在 handler 直接 parse JWT;token 驗證集中在 `model/auth/usecase`。
-- 密碼只存 bcrypt hash,不回傳、不寫 log。
-- `members.roles` 第一版是簡化 role key。正式 RBAC 可逐步補 roles collection,但不要破壞 `role_permissions` 的 tenant + role_key contract。
-- `Auth.DevHeaderFallback` 只給本機開發,正式環境應關閉。
-
-## AI Provider 擴充
-
-新增 provider 時:
-
-1. 在 `internal/model/ai/domain/enum` 新增 provider id。
-2. 在 `internal/model/ai/provider` 新增 adapter。
-3. 在 `internal/model/ai/usecase` registry 註冊 provider 與 models。
-4. 確保 adapter 回傳統一 `StreamEvent`。
-5. 不要改 `logic/ai` 的 SSE 格式。
-
-## Job Worker 擴充
-
-新增 job step 時優先註冊 runner handler:
-
-```go
-runner.RegisterStepHandler("analyze_8d", func(ctx context.Context, step job.StepContext) error {
- if err := step.Heartbeat(ctx); err != nil {
- return err
- }
- // do work, check cancel via job usecase if needed
- return nil
-})
-```
-
-規則:
-
-- Handler 不要直接操作 Mongo / Redis,透過 job usecase 更新進度、完成、失敗或取消。
-- 長任務每個 checkpoint 呼叫 `StepContext.Heartbeat` 或 `RefreshRunLock`。
-- 收到 cancel signal 後呼叫 `AcknowledgeCancel(jobId, workerID)`,不要自行把狀態改成 `cancelled`。
-- release lock 時必須帶 `workerID`;不要新增無 owner 的 release helper。
-
-## 前端設計規則(`web/`)
-
-巡樓 Console 前端在 `haixun-backend/web/`,視覺為**沉穩田園巡檢台**(動森感:天空、雲朵、奶油卡片、青綠 brand;**不是**任天堂 UI 複製)。
-
-**字體固定** Inter + Taipei Sans TC;圖示僅 `AcIcon` / `AuthDecor` 內原創 SVG 線條圖。**禁止** emoji、貼圖 JPG、咖啡色木質頂欄、Nook / 任天堂命名。樣式集中在 `index.css`(`--hx-*` token + `hx-*` / `ac-*` / `auth-*` class)。
-
-不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。
-
-### 視覺架構(登入前後共用)
-
-```text
-全頁背景 .hx-scene 灰藍天空 → 淡草地單一漸層(淺/深各一套,見 index.css)
-裝飾層 SceneDecor 雲朵(多朵緩動)+ 淡光暈 + 小葉子;登入與 Layout 共用
-奶油卡片 .auth-ticket 2px line 邊框、圓角 2rem、surface 底、可選 .ac-dialog-texture 點陣
-頂部品牌列 圖示 .auth-ticket-icon(brand-soft 底)+ ink 標題;不用獨立色塊 ribbon
-主內容 表單或 Outlet;內文頁用 PageTitle / Card(綠色 .ac-title-bar 僅內容區小標)
-桌面側欄 .ac-pocket-device 掌上終端外框;PATROL PAD 狀態列;固定尺寸 + .ac-pocket-scroll 內捲
-手機 .ac-dock 底部最多 4 格 +「更多」sheet
-```
-
-| 區域 | 元件 | 關鍵 class |
-|------|------|------------|
-| 未登入 | `AuthShell` + `LoginPage` / `RegisterPage` | `hx-scene` `auth-scene` `auth-ticket` `auth-welcome` `auth-shell-form` |
-| 已登入外殼 | `Layout` | `hx-scene` `ac-app-shell` `ac-app-header` `auth-ticket` `ac-app-main-inner` |
-| 背景裝飾 | `AuthDecor.tsx` → `SceneDecor` | `hx-scene-deco` `auth-cloud--*` |
-| 品牌小圖 | `AuthTicketIcon` | `auth-ticket-icon`(小屋+樹,原創 SVG) |
-| 側欄導覽 | `Layout` + `navApps` | `ac-pocket-device` `ac-app-tile` |
-| 手機導覽 | `MobileBottomNav` | `ac-dock` |
-
-**登入頁刻意不做的事**:上方不要獨立大色塊 header;表單上方**不要**再放 `ac-title-bar`「登入」大牌(品牌已在 `auth-welcome`)。註冊頁同理可省略重複大標。
-
-**已淘汰、勿加回**:`ac-island`(改用 `hx-scene`)、`ac-wood-bar` / 咖啡色木質頂欄、`public/ac/` 貼圖、Nook Phone 文案。
-
-### 技術棧與指令
-
-```text
-web/
- src/
- api/ # API client(envelope、JWT refresh)
- auth/ # AuthContext
- components/ # Layout、AuthShell、AuthDecor、ui、ThemeToggle、MobileBottomNav、AcIcon
- theme/ # ThemeContext(淺色 / 深色)
- pages/ # 路由頁面
- lib/ # acAssets(導覽 icon key)、jobStatus 等
- index.css # 設計 token 與場景樣式唯一來源
-```
-
-```bash
-make web-dev # dev server :5173,proxy 到 :8890
-make web-build # tsc + vite build
-```
-
-### 字型
-
-| 語言 | 字型 | 載入方式 |
-|------|------|----------|
-| 繁體中文 | **台北黑體 Taipei Sans TC** | npm `taipei-sans-tc`,在 `index.css` `@import` Regular + Bold |
-| 英文 | **Inter**(與 simular.co 相同,Google Fonts 免費) | `web/index.html` link |
-
-規則:
-
-- `body` / 中文標題:`Inter` + `Taipei Sans TC` 混排(`--font-sans`)。
-- 純英文裝飾字(導覽副標、Hero 小字):加 class `display-en`,使用 `--font-en`。
-- 中文 `line-height` 維持 **1.7+**;不要用過細字重當標題(標題用 `font-bold` / `font-black`)。
-- 只載入 Taipei Sans TC **Regular + Bold**,不要載入 Light,避免小字過細。
-- 不要改回 Noto Sans TC,也不要手寫 `#333` 這類裸色碼當主色。
-
-### 對比度與字級
-
-- 內文、表頭、表單 label、卡片說明:優先 `text-ink` / `text-ink-secondary`,**不要**拿 `text-muted` 當主要閱讀文字。
-- `text-muted` 只給次要提示(筆數、hint、placeholder 用 `text-subtle`)。
-- 表單輸入字級 **15px**(`text-[15px]`),輸入框底用 `bg-surface` 白底,確保與背景拉開。
-- 淺色 `muted` 約 `#5a6578`、深色約 `#b8c4d6`;改色時以「小字仍可舒適閱讀」為準,不要回到 `#94a3b8` 那種淡灰。
-
-### 主題(淺色 / 深色)
-
-- `ThemeProvider`(`src/theme/ThemeContext.tsx`)包住 App;偏好存 `localStorage` key:`haixun.theme`(`light` | `dark`)。
-- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。
-- 所有顏色必須走 CSS 變數 `--hx-*`,再映射到 Tailwind `@theme`(`bg-canvas`、`text-brand` 等)。
-- 切換按鈕用 `ThemeToggle`(`ac-btn-secondary` 樣式);`Layout` 頂欄與 `AuthShell` 右上角都要有。
-- **禁止**在元件裡寫死 `bg-slate-*`、`text-emerald-*`、`bg-amber-*` 等 Tailwind 預設色;語意狀態用 `text-success` / `text-warning` / `text-danger` 或 `jobStatus.ts` 的 badge class。
-
-淺色:低飽和灰藍天空 + 灰綠草地 + 奶油 `surface` + **brand 青綠**;深色:黃昏低對比、同一套 token 自動切換。頂欄與卡片內品牌區都用 **surface / ink / brand**,不要再用木色 `#c4a882` 當 header 底。
-
-### 場景與卡片 class(維護時對照)
-
-| Class | 用途 |
-|-------|------|
-| `.hx-scene` | 全頁天空→草地漸層(登入 + 已登入根節點) |
-| `.hx-scene-deco` / `SceneDecor` | 背景雲、光暈、葉子(`pointer-events: none`) |
-| `.auth-ticket` | 奶油主卡片外框(登入卡、已登入主內容區) |
-| `.auth-welcome` | 卡片內品牌列:圖示 + 標題 + 一句 tagline,底部分隔線 |
-| `.ac-app-header` | 已登入 sticky 頂欄:半透明 surface + blur,**非**木色 |
-| `.ac-title-bar` | 內容區綠色小標題(裝置色漸層);用於 `PageTitle` 等,**不**用於登入頁表單上方大牌 |
-| `.ac-pocket-device` | 側欄掌上終端;`--pocket-width`(28rem)、`--pocket-screen-height` 固定,內容在 `.ac-pocket-scroll` 捲動 |
-| `.ac-app-tile` / `.ac-dock` | App 格導覽、手機底欄 |
-| `.auth-shell-form` | 登入/註冊表單放大字級(僅 auth 頁) |
-
-側欄標示用 **PATROL PAD** 等中性英文裝飾字(`display-en`);圖示僅 `AcIcon` SVG。
-
-### 色彩 token(語意命名)
-
-開發時只用這些 Tailwind class(值定義在 `web/src/index.css`):
-
-| Token | 用途 |
-|-------|------|
-| `canvas` | 全頁背景 |
-| `surface` / `surface-muted` | 卡片、輸入框底 |
-| `ink` / `ink-secondary` / `muted` | 主文 / 次文 / 輔助 |
-| `line` | 邊框 |
-| `brand` / `brand-hover` / `brand-soft` | 主 CTA、active 導覽、連結 hover |
-| `glow` | 裝飾色塊(`.glow-blob-alt`) |
-| `success` / `warning` / `danger`(含 `*-soft`) | 狀態、錯誤、Job badge |
-
-主按鈕一律 `Button variant="primary"` → `bg-brand`,不要用全黑按鈕。
-
-### 圓角與陰影
-
-```text
---radius-sm 0.75rem 小元素、code
---radius-md 1.25rem Input / Textarea
---radius-lg 1.75rem Card
---radius-xl 2.25rem Hero、QuickLink、StatCard
---radius-pill 9999px Button、Badge、導覽 pill
-```
-
-陰影用 utility:`shadow-card`(一般卡片)、`shadow-soft`(主按鈕、Hero、`.auth-ticket`)。內容 Hero 可用 `ac-bulletin` + `ac-hero-gradient` token;全頁裝飾雲朵走 `SceneDecor`,不要另加會打架的強色 blob。
-
-### 共用元件(優先復用)
-
-新頁面必須從 `src/components/ui.tsx` 組裝,不要另寫一套按鈕樣式:
-
-| 元件 | 用途 |
-|------|------|
-| `PageTitle` | 頁面標題 + 副標 |
-| `Card` | 內容區塊 |
-| `Field` + `Input` / `Textarea` | 表單 |
-| `Button` | `primary` / `ghost` / `danger` / `soft` |
-| `Badge` | 標籤 pill(`brand` / `sky` / `success` / `warning` / `danger` / `neutral`) |
-| `StatCard` / `QuickLinkCard` | 總覽統計與快捷入口 |
-| `ErrorText` / `CopyableId` | 錯誤與可複製 ID |
-
-`Button` 必須渲染 `{children}`;文案用**中文動詞**(例:「建立背景任務」「重新載入任務列表」),不要留空白小框。
-
-### RWD(手機)
-
-- `< lg`:隱藏左側欄;**底部固定導覽**最多 **4 格**(總覽 / 任務 / 排程 / **更多**),不要把漢堡或 ⋯ 選單放在左上角。
-- 「更多」以底部 sheet 展開:AI、模板、設定、會員、權限、主題切換、登出。
-- 主內容加 `layout-main` 底部 padding,避開 tab bar + `safe-area-inset-bottom`。
-- 寬表格包 `overflow-x-auto` + `min-w-*`,避免小螢幕擠爆版面。
-
-### 版面與導覽
-
-- 已登入(桌面):`Layout` = `hx-scene` 背景 + `SceneDecor` + `ac-app-header`(品牌 + 角色 chip + `ThemeToggle`)+ 左 `ac-pocket-device` + 右 `auth-ticket` 主內容 `Outlet`。
-- 已登入(手機):同上頂欄;導覽走 `MobileBottomNav`(總覽/任務/排程/更多)。
-- 側欄 App 來源:`src/lib/acAssets.ts` 的 `navApps`;圖示 key 對應 `AcIcon`。
-- Active 導覽:`ac-app-tile--active`(brand-soft 底 + brand 字色);hover:`bg-brand-soft text-brand`。
-- 未登入:`AuthShell` 置中 `auth-ticket` + 右上 `ThemeToggle`;`auth-welcome` 內品牌,表單緊接說明文字。
-- 語氣:年輕、直接、短句;可帶「島民」「巡樓」等原創文案,避免企業八股與任天堂用語。
-
-### API 與狀態
-
-- JSON 一律走 `api/client.ts`(`code/message/data` envelope);需登入加 `{ auth: true }`。
-- AI 路由用 `X-Member-Authorization`;provider token 用 `Authorization`(見後端 Auth matrix)。
-- Job 狀態中文與 badge 色:`src/lib/jobStatus.ts`(`jobStatusLabel` / `jobStatusBadgeClass`),列表有進行中任務時可每 3 秒 refresh。
-- 不要在前端 parse JWT;`uid` / `tenant_id` 從 `AuthContext` 讀。
-
-### 新增頁面流程
-
-1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下,自動享有 `hx-scene` + 頂欄 + 主內容 `auth-ticket`)。
-2. 頁面內用 `PageTitle`(含 `.ac-title-bar` 小標)+ `Card` / `ac-bulletin` + `ui.tsx` 元件;色票只引用 semantic token。
-3. 若需新語意色,**先**改 `index.css` 的 `--hx-*` 與 `@theme`,再改元件;不要頁面內硬編色碼。
-4. 新導覽項:改 `acAssets.ts` 的 `navApps`,並在 `AcIcon` 補 SVG path。
-5. 完成後執行 `make web-build`。
-
-### 島民頁面互動(可推廣 runtime)
-
-掛在 `Layout` 底下的新頁面**自動**支援島民操作,不需每頁手寫 executor。
-
-模組入口:`web/src/lib/islander/index.ts`
-
-| 層 | 職責 |
-|----|------|
-| `pageSnapshot` | 掃描 `.ac-app-shell` 內可互動元素,產生 `hx-*` ref |
-| `islanderActions` | 解析/剝除 `islander-actions` JSON 區塊 |
-| `actionExecutor` | 執行 navigate/click/fill/select/scroll 等;可 `registerIslanderActionHandler` 擴充 |
-| `islanderAgent` | 串流回覆 → 執行 action → 回傳結果 → 自動 follow-up |
-| `buildIslanderContext` | 組裝送給後端的頁面快照 |
-
-**零設定(預設)**:路由掛在 `Layout` 即可;島民讀 DOM + `PageTitle` / `h1` 辨識頁面。預設**不**主動介紹這一頁;僅在使用者明確問頁面/操作時才附【可互動元素】(`userWantsPageContext`)。
-
-**可選增強**(擇一):
-
-1. `useIslanderPage({ title, purpose, hints, suggestions })` — 頁面內動態註冊說明
-2. `registerIslanderPage(/^\/foo/, { title, ... })` — 在 `siteGuide.ts` 或模組 init 靜態註冊
-3. HTML 慣例:`data-islander-label`(元素名稱)、`data-islander-kind`(類型)、`data-islander-ignore`(排除)、`data-islander-page-title`(頁名)
-
-Action 協定(AI 回覆末尾):
-
-```islander-actions
-[{ "type": "navigate", "path": "/settings" }, { "type": "click", "ref": "hx-3" }]
-```
-
-### 前端禁忌
-
-- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
-- 不要為單頁新增第三套配色、木質頂欄、或漸層彩虹按鈕。
-- 不要在登入/註冊頁加回獨立大牌 `ac-title-bar` 或咖啡色 header ribbon。
-- 不要讓 SSE / AI 直接吃 provider 原始 chunk(後端已 normalize)。
-- 不要用 `offset/limit` 呼叫列表 API;用 `page` / `pageSize`。
-
-## 驗證
-
-完成變更後至少執行:
-
-```bash
-cd haixun-backend
-go mod tidy
-make fmt
-go test ./...
-```
-
-有動到前端時另執行:
-
-```bash
-make web-build
-```
diff --git a/old/Makefile b/old/Makefile
deleted file mode 100644
index d322066..0000000
--- a/old/Makefile
+++ /dev/null
@@ -1,128 +0,0 @@
-SHELL := /bin/bash
-
-BACKEND_DIR := backend
-WEB_DIR := backend/web
-INFRA_DIR := infra
-DEPLOY_DIR := deploy
-
-INFRA_DEV_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.dev -f $(INFRA_DIR)/docker-compose.yml
-INFRA_PROD_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(INFRA_DIR)/docker-compose.yml
-DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_DIR)/docker-compose.yml
-
-.DEFAULT_GOAL := help
-
-.PHONY: help
-help: ## 顯示可用指令
- @grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \
- | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
-
-.PHONY: setup-env
-setup-env: ## 互動式產生 deploy/.env.prod(自動 URL-encode MongoDB 密碼)
- @$(DEPLOY_DIR)/setup.sh
-
-.PHONY: gen-api
-gen-api: ## 重新產生後端 API handler / logic / types
- $(MAKE) -C $(BACKEND_DIR) gen-api
-
-.PHONY: fmt
-fmt: ## 格式化後端 Go 程式
- $(MAKE) -C $(BACKEND_DIR) fmt
-
-.PHONY: test
-test: ## 執行後端測試
- $(MAKE) -C $(BACKEND_DIR) test
-
-.PHONY: init
-init: ## 初始化系統(建索引、種權限、建立 admin,讀 deploy/.env.prod)
- $(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init
-
-.PHONY: run
-run: ## 啟動後端 API
- $(MAKE) -C $(BACKEND_DIR) run
-
-.PHONY: dev-all
-dev-all: ## 啟動本機完整 dev stack(API、Go workers、Node workers、web)
- ./scripts/dev-all.sh
-
-.PHONY: dev-all-stop
-dev-all-stop: ## 停止本機 dev stack(釋放 8890 / 9101 / 9102 / web 等埠)
- ./scripts/dev-all-stop.sh
-
-.PHONY: web-dev
-web-dev: ## 啟動正式前端 dev server
- cd $(WEB_DIR) && npm install && npm run dev
-
-.PHONY: web-build
-web-build: ## 建置正式前端
- cd $(WEB_DIR) && npm install && npm run build
-
-.PHONY: verify
-verify: test web-build ## 後端測試與前端建置
-
-.PHONY: dev-infra
-dev-infra: ## 啟動本機 dev 資料服務(Mongo / Redis 容器,讀 deploy/.env.dev)
- @test -f $(DEPLOY_DIR)/.env.dev || cp $(DEPLOY_DIR)/.env.dev.example $(DEPLOY_DIR)/.env.dev
- @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.dev 再執行後續指令" >&2
- $(INFRA_DEV_COMPOSE) up -d
- $(INFRA_DEV_COMPOSE) ps
-
-.PHONY: dev-infra-down
-dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume)
- $(INFRA_DEV_COMPOSE) down
-
-.PHONY: dev-infra-recreate
-dev-infra-recreate: ## 清除 dev 資料 volume 並重啟(Mongo / Redis 全新狀態)
- $(INFRA_DEV_COMPOSE) down -v
- $(INFRA_DEV_COMPOSE) up -d
- $(INFRA_DEV_COMPOSE) ps
-
-.PHONY: prod-infra
-prod-infra: ## 啟動 prod 資料服務(Mongo / Redis,讀 deploy/.env.prod)
- @test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
- @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
- $(INFRA_PROD_COMPOSE) up -d
- $(INFRA_PROD_COMPOSE) ps
-
-.PHONY: prod-infra-down
-prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume)
- $(INFRA_PROD_COMPOSE) down
-
-.PHONY: prod-infra-recreate
-prod-infra-recreate: ## 清除 prod 資料 volume 並重啟(Mongo / Redis 全新狀態)
- $(INFRA_PROD_COMPOSE) down -v
- $(INFRA_PROD_COMPOSE) up -d
- $(INFRA_PROD_COMPOSE) ps
-
-.PHONY: prod-install
-prod-install: ## 一鍵部署 prod:複製 .env.prod、build 本地 image、啟動 app、init(需先啟動 infra)
- @test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
- @echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
- @echo ">>> 請確認 infra 已啟動(make prod-infra)" >&2
- $(DEPLOY_COMPOSE) build
- $(DEPLOY_COMPOSE) up -d
- $(DEPLOY_COMPOSE) --profile init run --rm init
- $(DEPLOY_COMPOSE) ps
-
-.PHONY: prod-rebuild
-prod-rebuild: ## 重新 build 本地 image 並重建 prod app stack(infra 不影響)
- $(DEPLOY_COMPOSE) build
- $(DEPLOY_COMPOSE) up -d
- $(DEPLOY_COMPOSE) ps
-
-.PHONY: prod-stop
-prod-stop: ## 關閉 prod app stack(保留 infra 與資料)
- $(DEPLOY_COMPOSE) down
-
-.PHONY: prod-stop-all
-prod-stop-all: ## 關閉 prod app + infra(保留資料 volume)
- $(DEPLOY_COMPOSE) down
- $(INFRA_PROD_COMPOSE) down
-
-.PHONY: prod-recreate
-prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動(app + infra + init)
- $(DEPLOY_COMPOSE) down -v
- $(INFRA_PROD_COMPOSE) down -v
- $(INFRA_PROD_COMPOSE) up -d
- $(DEPLOY_COMPOSE) up -d
- $(DEPLOY_COMPOSE) --profile init run --rm init
- $(DEPLOY_COMPOSE) ps
diff --git a/old/README.md b/old/README.md
deleted file mode 100644
index 56d4c4c..0000000
--- a/old/README.md
+++ /dev/null
@@ -1,30 +0,0 @@
-# 封存區(Legacy Archive)
-
-此目錄為 **2026-07-09** 起封存的舊巡樓 monorepo 內容,**不再作為開發主線**。
-
-新產品從 repo 根目錄 **從零重寫**,產物與決策見:
-
-- `docs/product/haixun-console/`(requirements / spec / plan / tasks)
-- 新前端目標:`apps/web`(建立中)
-- 新後端目標:`services/` 或 `backend/`(建立中,以 product 文件為準)
-
-## 內容大致對應
-
-| 路徑 | 原角色 |
-|------|--------|
-| `old/backend/` | 舊 Go gateway、workers、web Console、Node workers |
-| `old/deploy/`、`old/infra/`、`old/scripts/` | 部署與本機腳本 |
-| `old/extension/` | Chrome 擴充 |
-| `old/docs/` | 舊架構/計劃文件(非 product 流水線) |
-| `old/AGENTS.md` | 舊 agent 規範(可當參考,根目錄有新版精簡規範) |
-
-## 使用規則
-
-1. **只讀參考**:搬模式、契約、演算法時可查,不要在 `old/` 上繼續開功能分支當主線。
-2. **不要**把 `old/` 加回 CI 主 build(除非明確做遷移工具)。
-3. 需要對照舊 API 時優先看 `old/backend/generate/api/` 與 `old/backend/README.md`。
-4. 確認新系統穩定後,可另開 PR 決定是否從 git 歷史移除大目錄(目前僅搬移保留歷史)。
-
-## 還原(不建議)
-
-若誤搬,可用 git 還原該次 commit / checkout 路徑。正常開發請只改 repo 根下的新結構。
diff --git a/old/ai_threads_auto_account_ops.md b/old/ai_threads_auto_account_ops.md
deleted file mode 100644
index 45d2a89..0000000
--- a/old/ai_threads_auto_account_ops.md
+++ /dev/null
@@ -1,1678 +0,0 @@
-# AI 自動經營 Threads 帳號系統規格書
-
-> 目標:建立一套可以自動經營 Threads 帳號的 AI 系統。
-> 系統不是單純產文,而是能夠「找話題、判斷切角、生成內容、檢查品質、發布排程、回收成效、分析原因、更新策略」的閉環內容營運系統。
-
----
-
-## 0. 核心概念
-
-這套系統的核心不是「AI 幫我寫一篇文」,而是:
-
-```text
-AI 自動經營 Threads 帳號 =
-話題雷達
-+ 帳號大腦
-+ 內容公式池
-+ 切角推理
-+ 品質閘門
-+ 自動排程/發布
-+ 成效回收
-+ 策略自我更新
-```
-
-系統要做到:
-
-1. 自己發現新話題。
-2. 自己判斷話題適不適合帳號。
-3. 自己選擇內容任務,例如漲粉、互動、收藏、信任、人設。
-4. 自己從公式池選擇適合的開頭、中段、結尾、CTA。
-5. 自己產出多版本草稿。
-6. 自己檢查是否太 AI、太公式化、太像競品、太高風險。
-7. 自己排程或進入人工審核。
-8. 自己回收發文後的成效。
-9. 自己分析哪裡有效、哪裡沒效。
-10. 自己更新下一輪發文策略。
-
----
-
-## 1. 系統總流程
-
-```mermaid
-flowchart TD
- A[資料來源收集] --> B[資料清洗與解析]
- B --> C[帳號大腦 Brand Brain]
- C --> D[話題雷達 Topic Radar]
- D --> E[話題評分與分類]
- E --> F[內容策略決策器]
- F --> G[公式池與切角引擎]
- G --> H[AI 產文工廠]
- H --> I[品質閘門 Gatekeeper]
- I --> J{是否可發布}
- J -->|高信心| K[自動排程/發布]
- J -->|中信心| L[人工審核]
- J -->|低信心| M[退回重寫]
- L --> K
- M --> H
- K --> N[成效回收]
- N --> O[成效分析]
- O --> P[策略自我更新]
- P --> D
-```
-
----
-
-## 2. 系統模組拆分
-
-```mermaid
-flowchart LR
- A[Ingestion Service] --> B[Knowledge Brain Service]
- B --> C[Topic Radar Service]
- C --> D[Strategy Planner]
- D --> E[Formula Engine]
- E --> F[Content Generator]
- F --> G[Quality Gatekeeper]
- G --> H[Scheduler & Publisher]
- H --> I[Insight Collector]
- I --> J[Learning Engine]
- J --> B
-```
-
-### 2.1 Ingestion Service
-
-負責收集資料。
-
-資料來源包含:
-
-| 來源 | 用途 | 優先級 |
-|---|---|---:|
-| 使用者上傳 Excel / Markdown / PDF | 建立帳號知識庫與風格資料 | 高 |
-| 自己歷史貼文 | 學習帳號語氣、主題、成效 | 高 |
-| Threads 關鍵字搜尋 | 發現新話題 | 高 |
-| 指定競品帳號 | 拆解內容 DNA,不直接複製 | 高 |
-| Brave Search / Google Search | 補充外部資料與查證 | 中 |
-| RSS / 官方網站 | 專業帳號資料來源 | 中 |
-| 手動輸入筆記 | 補充人設、想法、禁忌 | 中 |
-| 留言與回覆 | 找粉絲真正關心的問題 | 高 |
-
-### 2.2 Knowledge Brain Service
-
-負責維護帳號大腦。
-
-包含:
-
-1. Brand Memory:帳號設定、人設、語氣、禁用規則。
-2. Knowledge Memory:文件、主題、知識點、可引用資料。
-3. Style Memory:句型、開頭、結尾、CTA、語氣節奏。
-4. Formula Memory:不同任務對應的內容公式。
-5. Feedback Memory:成效、成功模式、失敗模式、人工評分。
-
-### 2.3 Topic Radar Service
-
-負責找話題、去重、分類、評分。
-
-### 2.4 Strategy Planner
-
-負責決定:
-
-1. 今天要發什麼主題。
-2. 這篇文的任務是什麼。
-3. 要用什麼切角。
-4. 要用什麼情緒槓桿。
-5. 要不要進人工審核。
-
-### 2.5 Formula Engine
-
-負責從公式池中挑出:
-
-1. 開頭方式。
-2. 中段結構。
-3. 情緒槓桿。
-4. 結尾方式。
-5. CTA 類型。
-
-### 2.6 Content Generator
-
-負責產出多版本草稿,並根據帳號語氣改寫。
-
-### 2.7 Quality Gatekeeper
-
-負責檢查:
-
-1. AI 感。
-2. 公式感。
-3. 相似度。
-4. 事實風險。
-5. 醫療、金融、法律等高風險內容。
-6. 帳號一致性。
-7. 發文頻率與疲乏度。
-
-### 2.8 Scheduler & Publisher
-
-負責排程與發布。
-
-支援:
-
-1. Threads API 發布。
-2. Web Intent 半自動發文。
-3. 一鍵複製手動發文。
-
-### 2.9 Insight Collector
-
-負責回收成效。
-
-收集:
-
-1. impressions。
-2. likes。
-3. replies。
-4. reposts。
-5. quotes。
-6. follows gained。
-7. profile visits。
-8. reply sentiment。
-9. manual rating。
-
-### 2.10 Learning Engine
-
-負責分析成效並更新策略。
-
----
-
-## 3. 資料收集流程
-
-```mermaid
-flowchart TD
- A[資料來源] --> B[Raw Data Pool]
- B --> C[清洗 HTML / 雜訊]
- C --> D[切 Chunk]
- D --> E[抽取主題 Topic]
- D --> F[抽取實體 Entity]
- D --> G[抽取觀點 Claim]
- D --> H[抽取語氣 Style]
- D --> I[抽取公式 Pattern]
- E --> J[Knowledge Brain]
- F --> J
- G --> J
- H --> J
- I --> J
- J --> K[向量索引]
- J --> L[關係資料]
-```
-
-### 3.1 匯入文件
-
-支援格式:
-
-```text
-.xlsx
-.csv
-.md
-.txt
-.pdf
-.docx
-.zip, for Obsidian vault
-```
-
-### 3.2 Excel 匯入邏輯
-
-如果使用者上傳類似「小日子長大中.xlsx」這種內容檔案,可以這樣解析:
-
-| Sheet 類型 | 解析用途 |
-|---|---|
-| 發文 | 當作歷史貼文範例、語氣樣本、主題樣本 |
-| 產圖 | 當作 IG 輪播與視覺規格 |
-| 脆文 | 當作 Threads prompt 與內容風格樣本 |
-
-每一列轉成:
-
-```json
-{
- "source_type": "excel",
- "sheet_name": "發文",
- "title": "備孕搜尋紀錄",
- "content": "開始備孕後,Google 或 AI 可能比我本人更了解我的身體狀況...",
- "topics": ["備孕", "焦慮", "搜尋", "共鳴"],
- "content_type": "情緒共鳴文",
- "style_tags": ["溫柔", "真實", "口語", "不焦慮推進"]
-}
-```
-
-### 3.3 Obsidian 匯入邏輯
-
-Obsidian 不要當核心資料庫,只當匯入與輸出入口。
-
-解析內容:
-
-```text
-frontmatter
-# headings
-[[internal links]]
-#tags
-tables
-paragraphs
-attachments references
-```
-
-轉成:
-
-```json
-{
- "note_title": "第一次看生殖門診",
- "tags": ["備孕", "生殖門診"],
- "links": ["AMH", "FSH", "試管流程"],
- "chunks": [],
- "topics": [],
- "claims": [],
- "source_path": "/vault/備孕/第一次看生殖門診.md"
-}
-```
-
----
-
-## 4. 帳號大腦 Brand Brain
-
-```mermaid
-flowchart TD
- A[帳號基本設定] --> F[Brand Brain]
- B[歷史貼文] --> F
- C[上傳文件] --> F
- D[競品分析] --> F
- E[成效回饋] --> F
- F --> G[Brand Memory]
- F --> H[Knowledge Memory]
- F --> I[Style Memory]
- F --> J[Formula Memory]
- F --> K[Feedback Memory]
-```
-
-### 4.1 Brand Memory
-
-記住帳號是誰。
-
-```json
-{
- "account_name": "小日子長大中",
- "persona": "拾日",
- "positioning": "備孕、試管、孕育日常心境分享",
- "tone": ["溫柔", "真實", "陪伴", "不焦慮推進"],
- "avoid": ["太AI", "太醫療", "太雞湯", "保證效果", "過度正能量"],
- "content_pillars": [
- "備孕心情",
- "試管流程",
- "生殖門診",
- "伴侶陪伴",
- "等待焦慮",
- "幽默共鳴"
- ],
- "signature_lines": [
- "不是只有成功那天,這段路才算數",
- "把這些小小的日子,一點一點拾起來"
- ]
-}
-```
-
-### 4.2 Knowledge Memory
-
-記住專業知識與資料來源。
-
-```json
-{
- "topic": "第一次看生殖門診",
- "knowledge_points": [
- "可先整理月經週期",
- "可準備過去檢查報告",
- "可列出想問醫生的問題"
- ],
- "risk_level": "medium",
- "requires_fact_check": true,
- "source_ids": ["doc_001", "doc_002"]
-}
-```
-
-### 4.3 Style Memory
-
-記住帳號怎麼說話。
-
-```json
-{
- "opening_patterns": ["其實我一直覺得", "有時候不是", "今天突然想到"],
- "sentence_style": {
- "short_sentence_ratio": 0.65,
- "paragraph_spacing": "loose",
- "emoji_usage": "low"
- },
- "common_endings": ["今天先這樣也沒關係", "慢慢來也算有在往前"],
- "avoid_phrases": ["在這個快節奏的時代", "總而言之", "讓我們一起"]
-}
-```
-
-### 4.4 Formula Memory
-
-記住不同內容任務的公式。
-
-```json
-{
- "mission": "情緒共鳴文",
- "formula": {
- "opening": "真實情緒開場",
- "body": "生活細節堆疊",
- "emotion": "被理解感",
- "ending": "溫柔但不雞湯",
- "cta": "輕提問"
- },
- "use_case": ["等待焦慮", "月經來", "看到別人懷孕"],
- "avoid": ["不要寫成衛教文", "不要硬塞解方"]
-}
-```
-
-### 4.5 Feedback Memory
-
-記住什麼有效。
-
-```json
-{
- "insight": "反差開場的 reply rate 高於帳號平均 42%",
- "action": "提高反差開場權重",
- "confidence": 0.78,
- "applied_to_strategy_version": "v3"
-}
-```
-
----
-
-## 5. 話題雷達 Topic Radar
-
-```mermaid
-flowchart TD
- A[每日定時掃描] --> B[收集候選話題]
- B --> C[去重]
- C --> D[分類]
- D --> E[熱度分數]
- D --> F[帳號適配分數]
- D --> G[互動潛力]
- D --> H[可延伸性]
- D --> I[風險分數]
- E --> J[Topic Score]
- F --> J
- G --> J
- H --> J
- I --> J
- J --> K[候選題庫]
-```
-
-### 5.1 話題評分公式
-
-```text
-Topic Score =
-熱度分數 * 0.25
-+ 帳號適配度 * 0.30
-+ 可延伸性 * 0.15
-+ 互動潛力 * 0.15
-+ 資料可信度 * 0.10
-- 風險分數 * 0.20
-```
-
-### 5.2 話題分類
-
-| 類型 | 用途 | 範例 |
-|---|---|---|
-| 熱點話題 | 跟上討論,提高曝光 | 最近大家在討論 AMH |
-| 長尾知識 | 建立專業與收藏 | 第一次看生殖門診準備 |
-| 情緒共鳴 | 增加轉發與留言 | 等待開獎真的很累 |
-| 互動提問 | 養留言 | 大家第一次看診最怕什麼 |
-| 人設生活 | 讓帳號像真人 | 今天只是好好吃飯 |
-| 系列主題 | 長期內容資產 | 備孕小字典系列 |
-
-### 5.3 Candidate Topic Schema
-
-```json
-{
- "id": "topic_001",
- "account_id": "account_001",
- "name": "第一次看生殖門診,可以準備什麼?",
- "source": "keyword_search",
- "category": "長尾知識",
- "heat_score": 72,
- "fit_score": 94,
- "interaction_score": 68,
- "extend_score": 85,
- "risk_score": 35,
- "final_score": 78.5,
- "recommended_missions": ["收藏文", "互動文", "情緒共鳴文"]
-}
-```
-
----
-
-## 6. 內容策略決策器 Strategy Planner
-
-系統每次產文前,要先決定「這篇文的任務」。
-
-```mermaid
-flowchart TD
- A[候選話題] --> B[讀取帳號策略]
- B --> C[讀取最近貼文]
- C --> D[讀取成效記憶]
- D --> E[決定內容任務]
- E --> F[決定切角]
- F --> G[產生 Content Plan]
-```
-
-### 6.1 內容任務分類
-
-| 任務 | 目的 | 適合公式 |
-|---|---|---|
-| 漲粉文 | 擴散、追蹤 | 共鳴、反差、痛點、金句 |
-| 互動文 | 引留言 | 提問、二選一、經驗募集 |
-| 收藏文 | 提高保存 | 清單、步驟、懶人包 |
-| 信任文 | 建立專業與真實感 | 經驗、觀察、案例 |
-| 人設文 | 讓帳號有個性 | 生活、價值觀、吐槽 |
-| 轉換文 | 引導私訊/連結/資源 | 模板、工具、服務 |
-| 系列文 | 長期經營 | 連載、週更、固定單元 |
-
-### 6.2 Content Plan Schema
-
-```json
-{
- "topic": "第一次看生殖門診",
- "mission": "互動文",
- "target_audience": "準備第一次去生殖門診的備孕女性",
- "angle": "診間會腦袋空白,所以先問大家都準備什麼",
- "opening_type": "具體問題開場",
- "body_type": "列出 3 個自己想到但不確定的問題",
- "emotion": "緊張但想準備好",
- "ending_type": "邀請補充",
- "cta_type": "經驗募集型",
- "risk_level": "medium",
- "requires_human_review": true,
- "avoid": [
- "不要寫成衛教文",
- "不要太完整,保留留言空間",
- "不要溫柔金句收尾",
- "不要使用最近 7 天重複用過的開頭"
- ]
-}
-```
-
----
-
-## 7. 公式池與切角引擎 Formula Engine
-
-```mermaid
-flowchart TD
- A[Content Plan] --> B[選擇開頭公式]
- A --> C[選擇中段結構]
- A --> D[選擇情緒槓桿]
- A --> E[選擇結尾方式]
- A --> F[選擇 CTA]
- B --> G[組成產文指令]
- C --> G
- D --> G
- E --> G
- F --> G
-```
-
-### 7.1 開頭公式池
-
-| 類型 | 用途 | 範例 |
-|---|---|---|
-| 情緒直球 | 快速共鳴 | 其實我一直覺得,備孕最累的是等待。 |
-| 反差開場 | 提高停留 | 以前以為看診最難,後來發現最難的是不知道要問什麼。 |
-| 場景開場 | 增加真人感 | 今天在診間等叫號的時候,腦袋突然一片空白。 |
-| 衝突觀點 | 建立觀點 | 我不太喜歡別人說「放輕鬆就好」。 |
-| 問題開場 | 引互動 | 第一次看生殖門診,大家最怕的是什麼? |
-| 自嘲開場 | 增加親近感 | 備孕後,我的 Google 搜尋紀錄比我本人還像醫生。 |
-
-### 7.2 中段結構池
-
-| 類型 | 用途 |
-|---|---|
-| 生活細節堆疊 | 情緒共鳴文 |
-| 三點整理 | 收藏文、知識文 |
-| 自問自答 | 觀點文、焦慮文 |
-| 故事推進 | 人設文、信任文 |
-| 對比以前和現在 | 成長型內容 |
-| 錯誤觀念澄清 | 專業文 |
-| 故意不講滿 | 互動文 |
-
-### 7.3 情緒槓桿池
-
-| 情緒 | 適合內容 |
-|---|---|
-| 被理解感 | 共鳴文 |
-| 小小委屈 | 備孕、職場、生活 |
-| 無奈但不崩潰 | Threads 短文 |
-| 自嘲 | 反差、幽默 |
-| 溫柔補償 | 陪伴帳號 |
-| 不想說破的疲憊 | 深夜文 |
-| 終於有人講出來 | 轉發型內容 |
-
-### 7.4 結尾公式池
-
-| 類型 | 用途 |
-|---|---|
-| 溫柔陪伴 | 情緒文 |
-| 反諷補刀 | 幽默文 |
-| 開放提問 | 互動文 |
-| 收藏提醒 | 知識文 |
-| 留白不收 | 日記文 |
-| 系列預告 | 系列內容 |
-
-### 7.5 CTA 公式池
-
-| CTA 類型 | 範例 |
-|---|---|
-| 共鳴型 | 你也有這種感覺嗎? |
-| 經驗募集型 | 第一次看診你最慶幸自己有準備什麼? |
-| 補充型 | 有漏掉的也歡迎補充。 |
-| 收藏型 | 可以先存起來,下次看診前翻一下。 |
-| 轉發型 | 轉給那個一直叫你放輕鬆的人。 |
-| 無 CTA | 讓文章自然停在情緒裡。 |
-
----
-
-## 8. 防膩系統 Anti-Fatigue Engine
-
-避免每篇都長一樣。
-
-```mermaid
-flowchart TD
- A[新草稿] --> B[讀取最近 7/14/30 天貼文]
- B --> C[檢查開頭重複]
- B --> D[檢查結尾重複]
- B --> E[檢查 CTA 重複]
- B --> F[檢查任務比例]
- B --> G[檢查句型相似度]
- B --> H[計算膩感分數]
- H --> I{是否過高}
- I -->|是| J[退回重寫]
- I -->|否| K[通過]
-```
-
-### 8.1 防重複規則
-
-| 檢查項目 | 規則 |
-|---|---|
-| 開頭類型 | 連續 3 篇不能同類型 |
-| 結尾類型 | 連續 3 篇不能都是溫柔金句 |
-| CTA | 不能每篇都「你也會這樣嗎」 |
-| 文章長度 | 長短文要交錯 |
-| 情緒走向 | 不能每天都是焦慮到安慰 |
-| 內容任務 | 不能連續 5 篇都是共鳴文 |
-| 句型相似度 | 超過閾值退回重寫 |
-| 主題密度 | 同一主題設定冷卻期 |
-
-### 8.2 Fatigue Score
-
-```text
-Fatigue Score =
-Opening Similarity * 0.20
-+ Ending Similarity * 0.20
-+ CTA Similarity * 0.15
-+ Structure Similarity * 0.25
-+ Topic Repetition * 0.20
-```
-
-建議門檻:
-
-```text
-0 - 40:安全
-41 - 70:需要微調
-71 - 100:退回重寫
-```
-
----
-
-## 9. AI 產文工廠 Content Generator
-
-```mermaid
-flowchart TD
- A[Content Plan] --> B[生成初稿 A]
- A --> C[生成初稿 B]
- A --> D[生成初稿 C]
- B --> E[帳號語氣改寫]
- C --> E
- D --> E
- E --> F[真人感修稿]
- F --> G[Threads 格式優化]
- G --> H[版本評分]
- H --> I[選出最佳版本]
-```
-
-### 9.1 生成版本
-
-每次至少產 3 個版本:
-
-| 版本 | 目的 |
-|---|---|
-| A 版 | 穩定帳號風格 |
-| B 版 | 更有共鳴 |
-| C 版 | 更容易互動 |
-| D 版 optional | 更短、更 Threads |
-| E 版 optional | 更幽默、更反差 |
-
-### 9.2 產文指令格式
-
-```text
-請產出一篇 Threads 文。
-
-帳號:小日子長大中
-人設:拾日
-語氣:溫柔、真實、陪伴、不焦慮推進
-主題:第一次看生殖門診
-內容任務:互動文
-切角:診間會腦袋空白,所以先問大家都準備什麼
-開頭方式:具體問題開場
-中段方式:列出 3 個自己想到但不確定的問題
-情緒:緊張但想準備好
-結尾方式:邀請補充
-CTA:經驗募集型
-
-禁止:
-- 不要寫成衛教文
-- 不要太像 AI
-- 不要使用「在這個快節奏的時代」
-- 不要溫柔金句收尾
-- 不要超過 500 字
-- 不要直接給醫療建議
-```
-
----
-
-## 10. 品質閘門 Quality Gatekeeper
-
-```mermaid
-flowchart TD
- A[草稿] --> B[AI 感檢查]
- A --> C[公式感檢查]
- A --> D[競品相似度檢查]
- A --> E[事實查證]
- A --> F[風險檢查]
- A --> G[帳號一致性檢查]
- A --> H[發布頻率檢查]
- B --> I[總分]
- C --> I
- D --> I
- E --> I
- F --> I
- G --> I
- H --> I
- I --> J{決策}
- J -->|高分| K[可自動排程]
- J -->|中分| L[人工審核]
- J -->|低分| M[退回重寫]
-```
-
-### 10.1 檢查分數
-
-| 分數 | 說明 |
-|---|---|
-| ai_score | 越高代表越像 AI,需降低 |
-| formula_score | 越高代表公式感越重 |
-| brand_fit_score | 越高代表越符合帳號 |
-| risk_score | 越高代表越危險 |
-| similarity_score | 越高代表越像競品或過去貼文 |
-| engagement_potential | 預測互動潛力 |
-| freshness_score | 新鮮感 |
-
-### 10.2 發布決策
-
-```text
-總分 >= 85 且 risk_score < 30:可自動排程
-總分 70 - 84:進人工審核
-總分 < 70:退回重寫
-risk_score >= 60:強制人工審核
-similarity_score >= 75:退回重寫
-ai_score >= 70:退回真人感修稿
-```
-
-### 10.3 高風險內容
-
-以下內容預設不能全自動發布:
-
-1. 醫療建議。
-2. 金融投資建議。
-3. 法律判斷。
-4. 政治爭議。
-5. 指控個人或公司。
-6. 可能造成恐慌的資訊。
-7. 未查證的數據。
-
----
-
-## 11. 排程與發布 Scheduler & Publisher
-
-```mermaid
-flowchart TD
- A[通過品質閘門] --> B[選擇發布時間]
- B --> C[檢查頻率限制]
- C --> D[建立排程]
- D --> E{發布方式}
- E -->|API| F[Threads API 發布]
- E -->|Web Intent| G[開啟半自動發文]
- E -->|Manual| H[一鍵複製]
- F --> I[記錄 post_id]
- G --> I
- H --> I
- I --> J[等待成效回收]
-```
-
-### 11.1 發布策略
-
-| 模式 | 用途 |
-|---|---|
-| API 自動發 | 權限完整、低風險內容 |
-| Web Intent | API 權限不足時 |
-| 一鍵複製 | 初期 MVP 或人工操作 |
-
-### 11.2 排程策略
-
-系統需要根據過去成效找出最佳時間。
-
-第一版可先用簡單設定:
-
-```json
-{
- "posting_windows": ["12:00-13:30", "20:30-23:00"],
- "max_posts_per_day": 3,
- "min_gap_minutes": 180,
- "high_risk_requires_review": true
-}
-```
-
----
-
-## 12. 成效回收 Insight Collector
-
-```mermaid
-flowchart TD
- A[已發布貼文] --> B[1 小時成效]
- A --> C[6 小時成效]
- A --> D[24 小時成效]
- A --> E[72 小時成效]
- B --> F[Performance DB]
- C --> F
- D --> F
- E --> F
- F --> G[成效分析器]
-```
-
-### 12.1 回收時間點
-
-| 時間 | 用途 |
-|---|---|
-| 1 小時 | 初速判斷 |
-| 6 小時 | 當日表現 |
-| 24 小時 | 主要成效 |
-| 72 小時 | 長尾成效 |
-| 7 天 optional | 長期資料 |
-
-### 12.2 指標
-
-```json
-{
- "post_id": "post_001",
- "collected_at": "2026-07-02T21:00:00+08:00",
- "impressions": 12000,
- "likes": 580,
- "replies": 72,
- "reposts": 31,
- "quotes": 8,
- "follows_gained": 45,
- "profile_visits": 320,
- "engagement_rate": 0.057,
- "reply_sentiment": "positive"
-}
-```
-
----
-
-## 13. 成效分析 Performance Analyzer
-
-```mermaid
-flowchart TD
- A[Performance DB] --> B[按主題分析]
- A --> C[按公式分析]
- A --> D[按開頭分析]
- A --> E[按 CTA 分析]
- A --> F[按情緒分析]
- A --> G[按發布時間分析]
- B --> H[洞察]
- C --> H
- D --> H
- E --> H
- F --> H
- G --> H
-```
-
-### 13.1 分析問題
-
-系統每次分析要回答:
-
-1. 哪個主題表現最好?
-2. 哪種開頭最容易讓人回覆?
-3. 哪種 CTA 真的有效?
-4. 哪種情緒最容易被轉發?
-5. 哪種內容最容易漲粉?
-6. 哪種內容收藏價值最高?
-7. 哪個發文時間效果最好?
-8. 哪些內容應該減少?
-9. 哪些內容可以延伸成系列?
-
-### 13.2 分析結果範例
-
-```json
-{
- "account_id": "account_001",
- "period": "last_14_days",
- "insights": [
- {
- "type": "opening_pattern",
- "finding": "反差開場的平均 reply rate 比帳號平均高 42%",
- "recommendation": "下週提高反差開場權重,但避免連續使用",
- "confidence": 0.82
- },
- {
- "type": "content_mission",
- "finding": "收藏文的 follows gained 較低,但 profile visits 較高",
- "recommendation": "收藏文結尾加入更明確的人設介紹或系列預告",
- "confidence": 0.74
- }
- ]
-}
-```
-
----
-
-## 14. 策略自我更新 Learning Engine
-
-```mermaid
-flowchart TD
- A[成效分析] --> B[更新主題權重]
- A --> C[更新公式權重]
- A --> D[更新 CTA 權重]
- A --> E[更新發布時間]
- A --> F[更新禁用規則]
- A --> G[更新內容比例]
- B --> H[Strategy Version]
- C --> H
- D --> H
- E --> H
- F --> H
- G --> H
- H --> I[下一輪產文使用]
-```
-
-### 14.1 策略權重更新
-
-第一版不要 fine-tune,先更新權重。
-
-```json
-{
- "strategy_version": "v3",
- "content_mix": {
- "emotion_posts": 30,
- "knowledge_posts": 30,
- "interaction_posts": 20,
- "life_posts": 10,
- "conversion_posts": 10
- },
- "opening_weights": {
- "反差開場": 35,
- "情緒直球": 25,
- "問題開場": 20,
- "場景開場": 15,
- "金句開場": 5
- },
- "cta_weights": {
- "經驗募集型": 35,
- "收藏型": 25,
- "共鳴型": 20,
- "轉發型": 10,
- "無CTA": 10
- }
-}
-```
-
-### 14.2 策略版本化
-
-每次更新都要記錄原因。
-
-```json
-{
- "from_version": "v2",
- "to_version": "v3",
- "changes": [
- {
- "field": "content_mix.interaction_posts",
- "from": 15,
- "to": 20,
- "reason": "過去 14 天互動文 reply rate 高於帳號平均 38%"
- },
- {
- "field": "opening_weights.金句開場",
- "from": 15,
- "to": 5,
- "reason": "金句開場連續三週互動下降,且 AI 感評分偏高"
- }
- ]
-}
-```
-
----
-
-## 15. Dashboard 設計
-
-首頁應該像「AI 經營戰情室」。
-
-### 15.1 首頁區塊
-
-```text
-今日建議發文
-- 推薦主題
-- 推薦任務
-- 推薦切角
-- 推薦時間
-- 風險提醒
-
-草稿工廠
-- 待審核
-- 可排程
-- 需重寫
-- 已發布
-
-昨日成效
-- 最佳貼文
-- 最差貼文
-- 主要原因
-- 下一步建議
-
-策略更新
-- 本週提高什麼比例
-- 本週降低什麼比例
-- 新增禁用規則
-```
-
-### 15.2 單篇貼文分析頁
-
-```text
-貼文內容
-內容任務:互動文
-主題:第一次看生殖門診
-開頭類型:問題開場
-中段類型:故意不講滿
-CTA:經驗募集型
-
-成效:
-- impressions
-- likes
-- replies
-- reposts
-- follows gained
-
-AI 分析:
-- 為什麼有效
-- 哪裡可以更好
-- 是否適合延伸成系列
-- 下次建議用什麼變奏
-```
-
----
-
-## 16. DB Schema 草案
-
-### 16.1 accounts
-
-```sql
-CREATE TABLE accounts (
- id UUID PRIMARY KEY,
- name TEXT NOT NULL,
- platform TEXT NOT NULL DEFAULT 'threads',
- persona TEXT,
- positioning TEXT,
- tone_config JSONB,
- avoid_config JSONB,
- content_pillars JSONB,
- status TEXT DEFAULT 'active',
- created_at TIMESTAMP DEFAULT NOW(),
- updated_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.2 sources
-
-```sql
-CREATE TABLE sources (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- type TEXT NOT NULL,
- title TEXT,
- url TEXT,
- file_path TEXT,
- raw_content TEXT,
- parsed_status TEXT DEFAULT 'pending',
- metadata JSONB,
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.3 knowledge_chunks
-
-```sql
-CREATE TABLE knowledge_chunks (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- source_id UUID REFERENCES sources(id),
- content TEXT NOT NULL,
- topics JSONB,
- entities JSONB,
- claims JSONB,
- style_tags JSONB,
- risk_level TEXT,
- embedding VECTOR,
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.4 topics
-
-```sql
-CREATE TABLE topics (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- name TEXT NOT NULL,
- source TEXT,
- category TEXT,
- heat_score NUMERIC,
- fit_score NUMERIC,
- interaction_score NUMERIC,
- extend_score NUMERIC,
- risk_score NUMERIC,
- final_score NUMERIC,
- status TEXT DEFAULT 'candidate',
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.5 formula_pools
-
-```sql
-CREATE TABLE formula_pools (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- type TEXT NOT NULL,
- name TEXT NOT NULL,
- pattern TEXT,
- use_case JSONB,
- avoid JSONB,
- weight NUMERIC DEFAULT 1,
- created_at TIMESTAMP DEFAULT NOW(),
- updated_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.6 content_plans
-
-```sql
-CREATE TABLE content_plans (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- topic_id UUID REFERENCES topics(id),
- mission TEXT NOT NULL,
- target_audience TEXT,
- angle TEXT,
- opening_type TEXT,
- body_type TEXT,
- emotion TEXT,
- ending_type TEXT,
- cta_type TEXT,
- risk_level TEXT,
- requires_human_review BOOLEAN DEFAULT FALSE,
- avoid JSONB,
- status TEXT DEFAULT 'planned',
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.7 drafts
-
-```sql
-CREATE TABLE drafts (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- content_plan_id UUID REFERENCES content_plans(id),
- content TEXT NOT NULL,
- version_name TEXT,
- ai_score NUMERIC,
- formula_score NUMERIC,
- brand_fit_score NUMERIC,
- risk_score NUMERIC,
- similarity_score NUMERIC,
- freshness_score NUMERIC,
- status TEXT DEFAULT 'draft',
- created_at TIMESTAMP DEFAULT NOW(),
- updated_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.8 published_posts
-
-```sql
-CREATE TABLE published_posts (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- draft_id UUID REFERENCES drafts(id),
- platform_post_id TEXT,
- published_at TIMESTAMP,
- publish_mode TEXT,
- status TEXT DEFAULT 'published',
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.9 post_metrics
-
-```sql
-CREATE TABLE post_metrics (
- id UUID PRIMARY KEY,
- post_id UUID REFERENCES published_posts(id),
- collected_at TIMESTAMP NOT NULL,
- impressions INT,
- likes INT,
- replies INT,
- reposts INT,
- quotes INT,
- follows_gained INT,
- profile_visits INT,
- engagement_rate NUMERIC,
- metadata JSONB,
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.10 strategy_versions
-
-```sql
-CREATE TABLE strategy_versions (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- version TEXT NOT NULL,
- content_mix JSONB,
- opening_weights JSONB,
- cta_weights JSONB,
- formula_weights JSONB,
- posting_windows JSONB,
- change_reason JSONB,
- is_active BOOLEAN DEFAULT FALSE,
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
-### 16.11 learning_events
-
-```sql
-CREATE TABLE learning_events (
- id UUID PRIMARY KEY,
- account_id UUID REFERENCES accounts(id),
- post_id UUID REFERENCES published_posts(id),
- insight_type TEXT,
- finding TEXT,
- recommendation TEXT,
- confidence NUMERIC,
- applied_status TEXT DEFAULT 'pending',
- created_at TIMESTAMP DEFAULT NOW()
-);
-```
-
----
-
-## 17. API Endpoint 草案
-
-### 17.1 Account
-
-```text
-POST /api/accounts
-GET /api/accounts/:id
-PATCH /api/accounts/:id
-GET /api/accounts/:id/brain
-POST /api/accounts/:id/rebuild-brain
-```
-
-### 17.2 Source / Ingestion
-
-```text
-POST /api/accounts/:id/sources/upload
-POST /api/accounts/:id/sources/url
-POST /api/accounts/:id/sources/obsidian-import
-GET /api/accounts/:id/sources
-POST /api/sources/:source_id/parse
-```
-
-### 17.3 Topic Radar
-
-```text
-POST /api/accounts/:id/topic-radar/scan
-GET /api/accounts/:id/topics
-PATCH /api/topics/:topic_id
-POST /api/topics/:topic_id/create-plan
-```
-
-### 17.4 Content Plan / Draft
-
-```text
-POST /api/accounts/:id/content-plans
-GET /api/accounts/:id/content-plans
-POST /api/content-plans/:id/generate-drafts
-GET /api/content-plans/:id/drafts
-PATCH /api/drafts/:id
-POST /api/drafts/:id/quality-check
-POST /api/drafts/:id/approve
-POST /api/drafts/:id/reject
-```
-
-### 17.5 Publishing
-
-```text
-POST /api/drafts/:id/schedule
-POST /api/drafts/:id/publish
-GET /api/accounts/:id/calendar
-```
-
-### 17.6 Insights / Learning
-
-```text
-POST /api/published-posts/:id/collect-metrics
-GET /api/accounts/:id/performance
-POST /api/accounts/:id/analyze-performance
-POST /api/accounts/:id/update-strategy
-GET /api/accounts/:id/strategy-versions
-```
-
----
-
-## 18. Worker / Cron Jobs
-
-### 18.1 每日 Job
-
-```text
-每日 08:00
-- 掃描話題
-- 更新候選題庫
-- 產生今日內容建議
-```
-
-```text
-每日 10:00
-- 根據今日內容建議產生草稿
-- 執行品質檢查
-- 高信心進排程,中信心進審核
-```
-
-```text
-每日 12:00 / 21:00
-- 執行排程發文
-```
-
-### 18.2 成效回收 Job
-
-```text
-每小時
-- 找出已發布但未收 1h / 6h / 24h / 72h 成效的貼文
-- 呼叫平台 API 收集 insights
-- 寫入 post_metrics
-```
-
-### 18.3 每週學習 Job
-
-```text
-每週一 09:00
-- 分析過去 7 / 14 天成效
-- 產生 learning_events
-- 產生 strategy_version draft
-- 若 confidence 高,自動套用低風險策略更新
-- 若 confidence 中,進人工確認
-```
-
----
-
-## 19. MVP 開發階段
-
-### MVP 1:AI 社群助理
-
-目標:先證明「找題 + 產文 + 檢查」有價值。
-
-功能:
-
-1. 建立帳號。
-2. 上傳 Excel / Markdown。
-3. 建立 Brand Brain。
-4. 手動輸入關鍵字。
-5. AI 產生候選話題。
-6. AI 產生 Content Plan。
-7. AI 產 3 版草稿。
-8. AI 感與公式感檢查。
-9. 人工核准。
-10. 一鍵複製發文。
-
-驗收:
-
-```text
-使用者可以在 10 分鐘內:
-建立帳號 → 匯入資料 → 產出 5 篇可用 Threads 草稿。
-```
-
-### MVP 2:半自動營運
-
-目標:開始排程與成效回收。
-
-功能:
-
-1. Topic Radar 每日自動掃描。
-2. 自動產生每日 3-5 篇候選草稿。
-3. 人工審核。
-4. 排程發文。
-5. 手動或 API 回收成效。
-6. 產生週報。
-
-驗收:
-
-```text
-使用者每週只要審核草稿,系統能自動維持一週發文節奏。
-```
-
-### MVP 3:低風險自動發布
-
-目標:低風險內容自動發,高風險內容人工審。
-
-功能:
-
-1. 自動發布低風險草稿。
-2. 高風險草稿強制人工審核。
-3. 發文後自動回收成效。
-4. 自動更新部分策略權重。
-
-驗收:
-
-```text
-系統能連續 14 天自動經營帳號,且不出現高風險錯誤內容。
-```
-
-### MVP 4:自我改善閉環
-
-目標:讓系統真的越跑越準。
-
-功能:
-
-1. 每週自動分析成效。
-2. 自動調整內容比例。
-3. 自動調整公式權重。
-4. 自動產生內容節奏表。
-5. 生成策略版本與變更原因。
-
-驗收:
-
-```text
-系統能說明:
-這週為什麼提高互動文比例,
-為什麼降低某種開頭,
-下週策略和上週有何不同。
-```
-
----
-
-## 20. Coding Agent 開發任務建議
-
-### Phase 1:資料模型與帳號大腦
-
-```text
-1. 建立 DB schema。
-2. 建立 Account CRUD。
-3. 建立 Source upload API。
-4. 實作 Excel parser。
-5. 實作 Markdown parser。
-6. 建立 Brand Brain builder。
-7. 將 parsed content 寫入 knowledge_chunks。
-```
-
-### Phase 2:話題與策略
-
-```text
-1. 建立 Topic model。
-2. 實作手動 keyword topic import。
-3. 實作 Topic Score。
-4. 建立 Content Plan generator。
-5. 建立 Formula Pool seed data。
-```
-
-### Phase 3:產文與品質檢查
-
-```text
-1. 建立 Draft generator。
-2. 每個 Content Plan 產出 3 版草稿。
-3. 實作 AI 感檢查。
-4. 實作公式感檢查。
-5. 實作相似度檢查。
-6. 實作人工 approve / reject。
-```
-
-### Phase 4:排程與發布
-
-```text
-1. 建立 Schedule table。
-2. 實作草稿排程。
-3. 實作一鍵複製模式。
-4. 實作 Web Intent fallback。
-5. 預留 Threads API publisher adapter。
-```
-
-### Phase 5:成效與學習
-
-```text
-1. 建立 Published Posts。
-2. 建立 Post Metrics。
-3. 實作手動成效輸入。
-4. 實作 performance analyzer。
-5. 產生 learning_events。
-6. 建立 strategy_versions。
-```
-
----
-
-## 21. 產品原則
-
-### 21.1 不要做洗稿工具
-
-系統可以分析競品,但不能直接複製競品內容。
-
-允許:
-
-```text
-分析主題
-分析切角
-分析公式
-分析情緒槓桿
-分析 CTA
-分析互動方式
-```
-
-禁止:
-
-```text
-直接複製原文
-高度相似改寫
-批量搬運他人貼文
-規避平台規範的自動化抓取
-```
-
-### 21.2 人設一致,但形式要變
-
-```text
-語氣一致
-主題一致
-價值觀一致
-
-但:
-開頭要變
-結尾要變
-CTA 要變
-長短要變
-內容任務要變
-```
-
-### 21.3 高風險內容要保守
-
-醫療、金融、法律類內容,預設要查證與人工審核。
-
-### 21.4 自我改善要可解釋
-
-AI 每次調整策略,都要說明:
-
-```text
-調整了什麼
-為什麼調整
-依據哪段成效
-信心分數多少
-是否需要人工確認
-```
-
----
-
-## 22. 最終產品定位
-
-這不是一般 AI 發文工具。
-
-更精準的定位是:
-
-```text
-AI Threads Account Operator
-```
-
-或:
-
-```text
-AI Content Brain for Threads
-```
-
-一句話描述:
-
-```text
-讓 AI 像內容編輯一樣,替每個 Threads 帳號找題、選切角、產文、排程、分析成效,並根據結果持續調整經營策略。
-```
-
-產品真正的價值不是「會寫文」,而是:
-
-```text
-知道為什麼要發這篇,
-知道這篇打中誰,
-知道發完好不好,
-知道下一篇要怎麼改。
-```
-
----
-
-## 23. 最終閉環摘要
-
-```mermaid
-flowchart TD
- A[輸入帳號資料與知識] --> B[建立帳號大腦]
- B --> C[每日找話題]
- C --> D[挑選適合帳號的主題]
- D --> E[決定內容任務]
- E --> F[選公式與切角]
- F --> G[產生多版本草稿]
- G --> H[品質檢查]
- H --> I[排程或人工審核]
- I --> J[發布]
- J --> K[回收成效]
- K --> L[分析有效原因]
- L --> M[更新策略權重]
- M --> C
-```
-
----
-
-## 24. 建議第一版畫面
-
-### 24.1 帳號大腦頁
-
-```text
-帳號名稱
-人設
-定位
-語氣
-禁用語
-主題池
-公式池
-知識來源
-歷史貼文
-成效記憶
-```
-
-### 24.2 今日話題雷達
-
-```text
-今日推薦主題
-- 熱度
-- 帳號適配度
-- 互動潛力
-- 風險分數
-- 推薦內容任務
-```
-
-### 24.3 草稿工廠
-
-```text
-草稿列表
-- 內容任務
-- 主題
-- 公式組合
-- AI 感分數
-- 公式感分數
-- 風險分數
-- 狀態
-```
-
-### 24.4 成效分析頁
-
-```text
-已發布貼文
-成效數據
-AI 分析
-下次建議
-是否延伸系列
-```
-
-### 24.5 策略版本頁
-
-```text
-目前策略版本
-內容比例
-公式權重
-CTA 權重
-發布時間
-本次調整原因
-```
-
----
-
-## 25. 開發時的最小可交付結果
-
-第一個可交付版本只需要做到:
-
-```text
-1. 建立一個帳號。
-2. 上傳一份 Excel。
-3. 系統解析出帳號大腦。
-4. 手動輸入一個主題。
-5. 系統產出 Content Plan。
-6. 系統產出 3 篇 Threads 草稿。
-7. 系統檢查 AI 感與公式感。
-8. 使用者可以 approve / reject。
-9. 系統記住這次人工回饋。
-```
-
-這樣就可以開始驗證核心價值。
-
-不要一開始就把所有 API、圖譜、全自動發布都做完。
-先讓 AI 真的能寫出「像這個帳號」的內容,再讓它慢慢接管營運。
diff --git a/old/backend/Dockerfile b/old/backend/Dockerfile
deleted file mode 100644
index 883b3ab..0000000
--- a/old/backend/Dockerfile
+++ /dev/null
@@ -1,24 +0,0 @@
-FROM golang:1.24-alpine AS build
-
-WORKDIR /src
-RUN apk add --no-cache ca-certificates git
-COPY backend/go.mod backend/go.sum ./
-RUN go mod download
-COPY backend/ ./
-RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/gateway ./gateway.go \
- && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker \
- && CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/tool ./cmd/tool
-
-FROM alpine:3.20
-
-RUN apk add --no-cache ca-certificates tzdata \
- && addgroup -S haixun \
- && adduser -S -G haixun -h /app haixun
-WORKDIR /app
-COPY --from=build /out/gateway /app/gateway
-COPY --from=build /out/worker /app/worker
-COPY --from=build /out/tool /app/tool
-COPY backend/etc /app/etc
-USER haixun
-EXPOSE 8890
-CMD ["/app/gateway", "-f", "/app/etc/gateway.prod.yaml"]
diff --git a/old/backend/Makefile b/old/backend/Makefile
deleted file mode 100644
index 3b86a13..0000000
--- a/old/backend/Makefile
+++ /dev/null
@@ -1,50 +0,0 @@
-.PHONY: gen-api fmt test run web-dev web-build
-
-API_FILE := generate/api/gateway.api
-GOCTL_HOME := generate/goctl
-GOCTL ?= $(shell go env GOPATH)/bin/goctl
-
-ENV_FILE ?= ../deploy/.env.dev
-
-ifneq ($(wildcard $(ENV_FILE)),)
-# 把 deploy/.env.dev 的值 export 出來(給 make run / native binary 用;dev-all.sh 自行 source 故不走這條)
-include $(ENV_FILE)
-export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD
-export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET
-export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL
-export HAIXUN_STORAGE_S3_ENDPOINT HAIXUN_STORAGE_S3_PUBLIC_BASE_URL HAIXUN_STORAGE_S3_REGION
-export HAIXUN_STORAGE_S3_BUCKET HAIXUN_STORAGE_S3_ACCESS_KEY HAIXUN_STORAGE_S3_SECRET_KEY HAIXUN_STORAGE_S3_USE_PATH_STYLE
-export HAIXUN_SMTP_HOST HAIXUN_SMTP_PORT HAIXUN_SMTP_USERNAME HAIXUN_SMTP_PASSWORD HAIXUN_SMTP_FROM
-export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS
-export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME
-endif
-
-export HAIXUN_JOB_WORKER_TYPE ?= go-demo
-export HAIXUN_JOB_WORKER_ID ?= local-go-demo
-
-# 修改 generate/api/*.api 後執行,重新產生 routes / types / handler(logic 已存在則保留實作)
-gen-api:
- $(GOCTL) api go -api $(API_FILE) -dir . -style go_zero -home $(GOCTL_HOME)
-
-fmt:
- go fmt ./...
-
-test:
- go test ./...
-
-run:
- mkdir -p bin
- go build -o bin/gateway gateway.go
- ./bin/gateway -f etc/gateway.yaml
-
-run-dev:
- go run gateway.go -f etc/gateway.yaml
-
-init:
- go run ./cmd/tool/main.go init -f etc/gateway.yaml
-
-web-dev:
- cd web && npm install && npm run dev
-
-web-build:
- cd web && npm install && npm run build
diff --git a/old/backend/README.md b/old/backend/README.md
deleted file mode 100644
index 5e70226..0000000
--- a/old/backend/README.md
+++ /dev/null
@@ -1,423 +0,0 @@
-# Haixun Backend
-
-新的巡樓後端核心。這個資料夾刻意不直接複製 `template-monorepo` 的產物碼,只沿用它的架構模式、goctl handler template 概念與必要 runtime library,讓後續可以用更乾淨的邊界重建服務。
-
-## 目前範圍
-
-第一版先放六個核心能力:
-
-- `setting`:通用設定模型,支援 `scope + scope_id + key` 儲存不同類型設定。
-- `ai`:可替換 AI provider interface,第一版支援 OpenCode Go 與 Grok/xAI,並提供 SSE 串流回應。
-- `job`:通用背景任務系統,支援 template/run/schedule/event、Redis queue/lock、進度、retry 與 cooperative cancel。
-- `auth`:native email/password 登入、JWT access/refresh token、logout revoke。
-- `member`:目前登入會員的 profile 讀寫。
-- `permission`:permission catalog 與目前會員權限查詢。
-- `threads automation`:Threads 帳號、OAuth/API 診斷、發文 queue、補庫存、智慧時段、頻率護欄、成效追蹤與語調庫。
-
-暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。
-
-## 快速開始
-
-```bash
-cd haixun-backend
-go mod download
-make run
-```
-
-正式前端:
-
-```bash
-make web-dev # Vite dev server :5173,proxy 到 :8890
-make web-build # TypeScript + Vite build
-```
-
-預設服務:
-
-```text
-http://127.0.0.1:8890
-```
-
-健康檢查:
-
-```bash
-curl http://127.0.0.1:8890/api/v1/health
-```
-
-### 8D Node 爬蟲 worker 驗證
-
-`style-8d` job 由 `worker_type=node` 消費。啟動 Gateway 與 Redis 後,另開一個終端:
-
-```bash
-make node-worker-style-8d
-```
-
-也可以在 repo 根目錄執行:
-
-```bash
-npm run worker:style-8d
-```
-
-常用環境變數:
-
-```text
-HAIXUN_BACKEND_URL=http://127.0.0.1:8890
-HAIXUN_WORKER_SECRET=... # 若 etc/gateway.yaml 設了 InternalWorker.Secret,worker 需帶同一把
-HAIXUN_NODE_WORKER_ID=local-8d # 可選,方便辨識 lock holder
-HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1;要嚴格一點可調高
-```
-
-前端在人設詳情頁按「開始 8D 分析」後,任務會進入:
-
-```text
-準備爬蟲 -> 抓取公開樣本 -> AI 8D -> 儲存策略
-```
-
-人設 8D 分析**不需** Chrome extension session;Node worker 以匿名 Playwright 讀取對標帳號的公開個人頁。若公開頁無法讀到足夠樣本,job 會標記為 `failed` 並顯示原因,不會停在等待狀態。AI 分析使用會員設定頁的研究用 provider / API key(有選定經營帳號時沿用該帳號設定)。
-
-## 專案結構
-
-```text
-haixun-backend/
- gateway.go # go-zero server 入口
- Makefile # gen-api / fmt / test / run
- web/ # 正式巡樓 Console(Vite + React + TypeScript)
- dev-console/ # 本機診斷用最小開發面板,不是產品 UI
- etc/ # runtime config
- generate/
- api/ # goctl .api 定義
- goctl/api/handler.tpl # 從 template-monorepo 精簡改來的 handler 模板
- internal/
- config/ # config struct
- handler/ # HTTP handler,目前手寫;之後可由 goctl 生成
- logic/ # API 編排層
- model/
- setting/ # 通用設定 model
- ai/ # AI provider interface + adapter
- job/ # Job template/run/schedule/event usecase + repository
- auth/ # JWT token issue/refresh/logout + Redis revoke store
- member/ # Native member profile + password hash
- permission/ # Permission catalog + role permission mapping
- publish_queue/ # Threads 發文 queue、guarded transition、retry/missed
- publish_inventory/ # 自動補庫存 policy
- publish_guard/ # 發文頻率護欄與 pause/resume
- publish_queue_event/ # Queue 狀態轉移與告警事件
- style_preset/ # 人設語調 preset、CTA、禁用詞
- worker/ # 常駐背景 worker / scheduler / reaper
- library/ # 最小 runtime library
- response/ # 統一 JSON response envelope
- svc/ # ServiceContext 組裝依賴
- types/ # API request/response types
-```
-
-## 分層規則
-
-## Response 與錯誤碼標準
-
-所有一般 JSON API 都必須回傳同一層 envelope:
-
-```json
-{
- "code": 102000,
- "message": "SUCCESS",
- "data": {}
-}
-```
-
-成功固定:
-
-```text
-HTTP 200
-code = 102000
-message = SUCCESS
-```
-
-失敗格式:
-
-```json
-{
- "code": 33101000,
- "message": "缺少 AI provider token",
- "error": {
- "biz_code": "33101000",
- "scope": 33,
- "category": 104,
- "detail": 0
- }
-}
-```
-
-錯誤碼採 `SSCCCDDD`:
-
-```text
-SS = scope,服務或模組範圍
-CCC = category,錯誤分類
-DDD = detail,細分錯誤碼,未細分時為 000
-```
-
-目前 scope:
-
-```text
-10 = Facade / request parse / validation
-32 = Setting
-33 = AI
-34 = Job
-35 = Auth
-36 = Member
-37 = Permission
-```
-
-常用 category:
-
-```text
-101 = InputInvalidFormat
-104 = InputMissingRequired
-204 = DBUnavailable
-301 = ResourceNotFound
-303 = ResourceConflict
-401 = AuthUnauthorized
-505 = AuthForbidden
-601 = SystemInternal
-802 = ServiceThirdParty
-```
-
-實作規則:
-
-- Handler 成功/失敗都用 `internal/response.Write`,SSE endpoint 例外。
-- Request parse / validation 錯誤用 `response.WrapRequestError`,會落在 Facade scope。
-- Model/usecase 內建立錯誤時使用 `errors.For(code.)` builder,不要手刻數字。
-- 不要把 provider 原始錯誤完整洩漏到前端;必要時只保留可排查的摘要。
-
-### 分頁標準
-
-列表型 API 的 query 使用 `page` / `pageSize`:
-
-```text
-GET /api/v1/settings/user/user_123?page=1&pageSize=10
-```
-
-回應的分頁資訊放在 `data.pagination`,資料陣列放在 `data.list`:
-
-```json
-{
- "code": 102000,
- "message": "SUCCESS",
- "data": {
- "pagination": {
- "total": 42,
- "page": 1,
- "pageSize": 10,
- "totalPages": 5
- },
- "list": []
- }
-}
-```
-
-規則:
-
-- `page` 從 1 開始。
-- `pageSize <= 0` 時由 server 套用預設值。
-- `pageSize` 超過 server 上限時由 server 截斷。
-- `totalPages = ceil(total / pageSize)`。
-- response 內的 `page/pageSize` 必須回傳 server 正規化後的值。
-
-### logic
-
-`internal/logic/*` 只負責一次 API 請求的流程編排:
-
-- 轉換 HTTP types 與 usecase DTO
-- 呼叫一個或多個 model usecase
-- 不直接操作 Mongo / Redis
-- 不放 provider HTTP 細節
-
-### model
-
-`internal/model/*` 放可重複使用的業務能力:
-
-- `domain/entity`:資料結構
-- `domain/repository`:repository interface
-- `domain/usecase`:usecase interface 與 DTO
-- `repository`:Mongo / Redis 實作
-- `usecase`:業務能力實作
-
-### provider
-
-`internal/model/ai/provider` 只負責外部 AI API adapter:
-
-- 不讀 setting
-- 不碰 HTTP handler
-- 不存 token
-- token 每次由 request 帶入
-
-## Setting Model
-
-設定使用 typed setting 形式:
-
-```json
-{
- "scope": "user",
- "scope_id": "user_123",
- "key": "ai.default",
- "value": {
- "provider": "opencode-go",
- "model": "deepseek-v4-pro",
- "temperature": 0.7,
- "max_tokens": 2000
- },
- "version": 1
-}
-```
-
-API:
-
-```text
-GET /api/v1/settings/:scope/:scope_id?page=1&pageSize=10
-GET /api/v1/settings/:scope/:scope_id/:key
-PUT /api/v1/settings/:scope/:scope_id/:key
-DELETE /api/v1/settings/:scope/:scope_id/:key
-```
-
-`setting` model 不知道 AI、Threads、crawler 等業務含義。各業務 model 自己解讀對應 key 的 value。
-
-## Auth / Member / Permission
-
-這版從 `template-monorepo` 精簡搬入會員、權限與 token 的核心概念,但不搬 OAuth / OTP / MFA / Zitadel 依賴。
-
-Auth 採 native email/password:
-
-```text
-POST /api/v1/auth/register
-POST /api/v1/auth/login
-POST /api/v1/auth/refresh
-POST /api/v1/auth/logout
-```
-
-封測期間 `register` 需要 admin 會員 JWT,公開登入頁不提供自行註冊;之後開放註冊時再移除這層權限。`register` / `login` 回傳:
-
-```json
-{
- "access_token": "...",
- "refresh_token": "...",
- "expires_in": 900,
- "uid": "user_uid",
- "token_type": "Bearer"
-}
-```
-
-保護路由使用:
-
-```http
-Authorization: Bearer
-```
-
-Admin 島民管理 API(封測用,皆需 admin JWT):
-
-```text
-GET /api/v1/members?page=1&pageSize=20
-PATCH /api/v1/members/:uid/profile
-PATCH /api/v1/members/:uid/roles
-PATCH /api/v1/members/:uid/password
-```
-
-角色第一版只支援 `admin` / `user`,admin 不能調整自己的角色,避免把自己降權後鎖住管理入口。Admin 可協助更新島民基本資訊、狀態與商務 email/phone 驗證狀態。
-封測建立帳號權限由 `Permission.SeedAdminRegister` 控制是否 seed 進 Mongo;之後要開放公開註冊時先關 config,再清掉 DB 裡的 `auth.register` permission 即可。`member.manage` 留在一般權限目錄,但 user 預設不會拿到,仍只有 admin 可管理島民。
-
-本機開發可以開啟 `Auth.DevHeaderFallback`,用 header 模擬登入:
-
-```http
-X-Tenant-ID: default
-X-UID: user_uid
-```
-
-Member API:
-
-```text
-GET /api/v1/members/me
-PATCH /api/v1/members/me
-```
-
-Permission API:
-
-```text
-GET /api/v1/permissions/catalog?tree=true
-GET /api/v1/permissions/me?include_tree=true
-```
-
-資料模型:
-
-- `members`:tenant-scoped profile、email、bcrypt password hash、roles。
-- `permissions`:平台 permission catalog。
-- `role_permissions`:tenant + role_key 對 permission catalog 的綁定。
-- Redis `auth:jwt:*`:access/refresh pair 與 blacklist。Redis 未配置時仍可簽發 token,但 refresh/logout revoke 不會持久化。
-
-## AI Provider
-
-AI token 不存在 config,呼叫時每次帶入,且**只放 HTTP header**,不要放 JSON body(避免 log / 回應洩漏):
-
-```http
-Authorization: Bearer sk-...
-Content-Type: application/json
-
-{
- "provider": "opencode-go",
- "model": "deepseek-v4-pro",
- "messages": [
- { "role": "user", "content": "請幫我寫一段文案" }
- ]
-}
-```
-
-API:
-
-```text
-GET /api/v1/ai/providers
-POST /api/v1/ai/providers/:provider/models
-POST /api/v1/ai/chat
-POST /api/v1/ai/chat/stream
-```
-
-- `GET /providers`:只回傳 catalog(id、label、streams),不含 models、不含 token。
-- `POST /providers/:provider/models`:向 provider 的 `/models` 動態拉清單,需帶 `Authorization: Bearer `。
-- 回應與錯誤訊息不會 echo token;provider 原始錯誤 body 也不會直接回傳給前端。
-
-串流 endpoint 使用 SSE:
-
-```text
-event: delta
-data: {"type":"delta","text":"..."}
-
-event: done
-data: {"type":"done","finish_reason":"stop"}
-```
-
-## Job System
-
-Job 系統的詳細設計在 `docs/job-system-plan.md`。目前 runtime 原則:
-
-- MongoDB 的 `job_runs` 是狀態真相來源;claim、cancel、complete、fail、retry 必須使用 conditional update,避免 worker 與 API 互相覆蓋狀態。
-- Redis `jobs:lock:` 的 value 是 `workerID`;release / refresh 必須檢查 owner,只能由持有 lock 的 worker 操作。
-- Worker 執行長任務時要定期呼叫 `RefreshRunLock(jobId, workerID, ttlSeconds)`,避免 reaper 誤判過期。
-- Runner 支援 `RegisterStepHandler(stepID, handler)` 註冊自訂 step handler;未註冊時會走 demo handler。自訂 handler 可用 `StepContext.Heartbeat` 續約 lock。
-- 取消採 cooperative cancellation:API 先寫 `cancel_requested` 與 Redis cancel signal,worker checkpoint 讀取後呼叫 `AcknowledgeCancel(jobId, workerID)`。
-
-## OpenCode Go 注意事項
-
-第一版 OpenCode Go 先走 OpenAI-compatible `/chat/completions`:
-
-```text
-https://opencode.ai/zen/go/v1/chat/completions
-```
-
-目前已處理 Kimi 模型 `temperature = 1` 的特殊規則。部分 OpenCode Go 模型官方文件標示為 Anthropic-compatible `/messages`,後續可在 `internal/model/ai/provider` 新增 messages adapter,不需要改 logic 或前端 SSE contract。
-
-## 下一步建議
-
-1. 用 `goctl` 重新生成 handler / logic / types,確認 `.api` 與手寫版本對齊。
-2. 補 `setting` repository 測試與 Mongo integration 測試。
-3. 補 AI provider mock,讓 `logic/ai` 不需要真的打 provider 也能測。
-4. 新增 credential service 或 Vault/KMS 整合,但不要把 token 放進 provider config。
-5. 新增 worker/job model,讓 Go worker 與 Node Playwright worker 共用同一套 job contract。
-
-## 設計文件
-
-- [Job 核心系統規劃](docs/job-system-plan.md):通用 job template、run、schedule、事件、取消語意與 worker contract。
diff --git a/old/backend/cmd/tool/main.go b/old/backend/cmd/tool/main.go
deleted file mode 100644
index c6b8c35..0000000
--- a/old/backend/cmd/tool/main.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package main
-
-import (
- "context"
- "flag"
- "fmt"
- "os"
- "strings"
- "time"
-
- "haixun-backend/internal/bootstrap"
- "haixun-backend/internal/config"
-
- "github.com/zeromicro/go-zero/core/conf"
-)
-
-func main() {
- bootstrap.LoadInfraEnv()
-
- if len(os.Args) < 2 {
- printUsage()
- os.Exit(1)
- }
- switch os.Args[1] {
- case "init":
- if err := runInit(os.Args[2:]); err != nil {
- fmt.Fprintf(os.Stderr, "[tool] error: %v\n", err)
- os.Exit(1)
- }
- default:
- fmt.Fprintf(os.Stderr, "[tool] unknown command: %s\n", os.Args[1])
- printUsage()
- os.Exit(1)
- }
-}
-
-func runInit(args []string) error {
- fs := flag.NewFlagSet("init", flag.ExitOnError)
- configFile := fs.String("f", "etc/gateway.yaml", "config file")
- tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
- email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@30cm.net"), "bootstrap admin email")
- password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", ""), "bootstrap admin password")
- displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
- if err := fs.Parse(args); err != nil {
- return err
- }
-
- var cfg config.Config
- conf.MustLoad(*configFile, &cfg, conf.UseEnv())
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
- defer cancel()
-
- report, err := bootstrap.Init(ctx, cfg, bootstrap.InitOptions{
- TenantID: strings.TrimSpace(*tenantID),
- AdminEmail: strings.TrimSpace(*email),
- AdminPass: *password,
- DisplayName: strings.TrimSpace(*displayName),
- })
- if err != nil {
- return err
- }
-
- fmt.Fprintf(os.Stderr, "[tool] indexes ensured\n")
- fmt.Fprintf(os.Stderr, "[tool] permissions catalog seeded\n")
- fmt.Fprintf(os.Stderr, "[tool] role_permissions seeded (admin=all, user=default)\n")
- if report.AdminCreated {
- fmt.Fprintf(os.Stderr, "[tool] admin created uid=%s email=%s tenant=%s\n", report.AdminUID, *email, *tenantID)
- } else {
- fmt.Fprintf(os.Stderr, "[tool] admin exists uid=%s email=%s tenant=%s (roles ensured admin)\n", report.AdminUID, *email, *tenantID)
- }
- fmt.Printf("export INIT_TENANT_ID=%s\n", *tenantID)
- fmt.Printf("export INIT_ADMIN_EMAIL=%s\n", *email)
- fmt.Printf("export INIT_ADMIN_PASSWORD=%s\n", *password)
- fmt.Printf("export INIT_ADMIN_UID=%s\n", report.AdminUID)
- return nil
-}
-
-func envOr(key, fallback string) string {
- if v := strings.TrimSpace(os.Getenv(key)); v != "" {
- return v
- }
- return fallback
-}
-
-func printUsage() {
- fmt.Fprintf(os.Stderr, "usage:\n")
- fmt.Fprintf(os.Stderr, " tool init [-f etc/gateway.yaml] [-tenant default] [-email admin@haixun.local] [-password ...]\n")
-}
diff --git a/old/backend/cmd/worker/main.go b/old/backend/cmd/worker/main.go
deleted file mode 100644
index d54f0a0..0000000
--- a/old/backend/cmd/worker/main.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package main
-
-import (
- "context"
- "flag"
- "fmt"
- "os"
- "os/signal"
- "syscall"
-
- "haixun-backend/internal/bootstrap"
- "haixun-backend/internal/config"
- "haixun-backend/internal/svc"
-
- "github.com/zeromicro/go-zero/core/conf"
-)
-
-var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file")
-
-func main() {
- flag.Parse()
- bootstrap.LoadInfraEnv()
-
- var c config.Config
- conf.MustLoad(*configFile, &c, conf.UseEnv())
- if !c.JobWorker.Enabled && !c.JobScheduler.Enabled && !c.JobReaper.Enabled && !c.ThreadsTokenRefresh.Enabled && !c.ThreadsPublishQueue.Enabled {
- fmt.Fprintln(os.Stderr, "[worker] at least one worker, scheduler, reaper, token sweeper, or publish queue sweeper must be enabled")
- os.Exit(1)
- }
-
- sc := svc.NewServiceContext(c)
- defer sc.Close(context.Background())
-
- fmt.Printf(
- "[worker] started type=%s (job=%v scheduler=%v reaper=%v token_sweeper=%v publish_sweeper=%v)\n",
- c.JobWorker.WorkerType,
- c.JobWorker.Enabled,
- c.JobScheduler.Enabled,
- c.JobReaper.Enabled,
- c.ThreadsTokenRefresh.Enabled,
- c.ThreadsPublishQueue.Enabled,
- )
-
- ch := make(chan os.Signal, 1)
- signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
- <-ch
- fmt.Println("[worker] shutting down")
-}
diff --git a/old/backend/dev-console/README.md b/old/backend/dev-console/README.md
deleted file mode 100644
index cc17e90..0000000
--- a/old/backend/dev-console/README.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# 巡樓 Dev Console
-
-重做正式前端前的**最小開發面板**:登入、人設 CRUD、8D 公開爬蟲任務與 job 輪詢。
-
-## 啟動
-
-```bash
-# 1. 後端 API(另開終端)
-cd backend
-go run gateway.go -f etc/gateway.yaml
-
-# 2. Node 8D worker(另開終端,人設 8D 需要)
-cd backend/worker
-npm install
-npm run style-8d
-
-# 3. Dev Console
-cd backend/dev-console
-npm install
-npm run dev
-```
-
-瀏覽器開啟 http://127.0.0.1:5173 。API 透過 Vite proxy 轉到 `http://127.0.0.1:8890`,無需處理 CORS。
-
-## 使用順序
-
-1. 使用已建立帳號登入(tenant 預設 `default`;封測期間不開放公開註冊)
-2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存**
-3. 建立人設 → 點選列表中的一筆
-4. 填對標 Threads 帳號 → **開始 8D**
-5. 自動輪詢 job;完成後下方人設詳情會出現 `style_profile`
-
-## 注意
-
-- 8D 分析使用**研究用** provider / model + API key(與 Chrome session 無關)
-- 若尚無經營帳號,Dev Console 會自動建立「Dev Console」帳號(AI key 實際存會員 scope)
-- 僅供本機開發,不是產品 UI
diff --git a/old/backend/dev-console/index.html b/old/backend/dev-console/index.html
deleted file mode 100644
index 7607a45..0000000
--- a/old/backend/dev-console/index.html
+++ /dev/null
@@ -1,2188 +0,0 @@
-
-
-
-
-
- 巡樓 Dev Console
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
貼文成效追蹤
-
瀏覽、互動與帳號洞察一次看完。數字來自 Threads Graph API,需帳號已 OAuth 連線。
-
-
-
-
- 篇
- 載入成效
-
-
-
-
-
-
-
-
- 各 API scope 能追蹤什麼(展開)
-
-
-
-
-
-
-
- 3. 發文佇列
- 排程或立即發文;到期由後端 sweeper 透過 Threads API 發佈,並自動排 +1h / +24h / +7d 成效檢查。
-
-
-
-
-
-
-
-
-
-
加入佇列
-
重新載入
-
-
-
-
-
-
- 4. 發文健康度
- 已發佈貼文與成效 checkpoint 彙總(來自 publish_queue + publish_analytics)。
-
- 載入健康度
-
-
-
-
-
-
-
- 2b. Threads API 手動測試台
- 針對選中帳號逐項呼叫 Graph API;寫入類(發文/刪文)會真的改 Threads 上的內容。
-
-
-
個人資料 /me
-
threads_basic
-
執行
-
-
-
我的貼文列表
-
threads_basic
-
執行
-
-
-
關鍵字搜尋
-
threads_keyword_search
-
-
搜尋
-
-
-
帳號查詢
-
threads_profile_discovery
-
-
查詢
-
-
-
提及 mentions
-
threads_manage_mentions
-
執行
-
-
-
帳號洞察 insights
-
threads_manage_insights
-
執行
-
-
-
地點搜尋
-
threads_location_tagging
-
-
搜尋
-
-
-
貼文統計
-
threads_manage_insights · GET /{media-id}/insights
-
-
查詢
-
-
-
貼文洞察 insights
-
threads_manage_insights
-
-
查詢
-
-
-
貼文留言
-
threads_read_replies
-
-
抓留言
-
-
-
發文(真的會發)
-
threads_content_publish
-
-
發文
-
-
-
回覆留言(真的會發)
-
threads_content_publish
-
-
-
發回覆
-
-
-
刪除貼文
-
threads_delete
-
-
刪除
-
-
-
隱藏 / 顯示留言
-
threads_manage_replies
-
-
-
套用
-
-
-
同步到 Instagram
-
threads_share_to_instagram
-
-
同步
-
-
-
刷新 long-lived token
-
token refresh
-
刷新並存回 DB
-
-
- 結果會顯示在下方;成功後可把 media_id 複製到其他卡片繼續測。
- (尚無結果)
-
-
-
- 3. AI 設定(8D 研究用)
- 登入後自動載入經營帳號與 AI 設定
-
-
-
-
-
-
-
-
-
-
-
-
- 讀取模型列表
-
-
- 儲存 AI 設定
- 重新載入
-
-
- 8D 分析使用研究用 provider / model。Key 存於後端(會員 scope),Dev Console 不會把完整 key 留在 localStorage。
-
-
-
-
-
- 5. 8D 風格分析(公開爬蟲,不需 session)
-
-
-
- 開始 8D
-
- 需先完成上方 AI 設定,並啟動 Go API + Node worker(style-8d)。
- (尚無任務狀態)
-
-
-
-
- 查詢一次
- 自動輪詢
-
-
- (尚無任務)
-
-
-
- 6. 人設詳情
-
- 重新載入選中人設
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/old/backend/dev-console/package-lock.json b/old/backend/dev-console/package-lock.json
deleted file mode 100644
index 915a3d4..0000000
--- a/old/backend/dev-console/package-lock.json
+++ /dev/null
@@ -1,1141 +0,0 @@
-{
- "name": "haixun-dev-console",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "haixun-dev-console",
- "devDependencies": {
- "vite": "^6.2.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
- "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
- "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
- "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
- "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
- "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
- "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
- "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
- "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
- "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
- "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
- "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
- "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
- "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
- "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
- "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
- "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
- "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
- "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
- "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
- "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
- "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
- "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
- "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
- "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
- "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
- "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
- "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
- "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
- "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
- "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
- "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
- "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
- "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
- "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
- "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
- "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
- "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
- "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
- "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
- "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
- "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
- "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "glibc"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
- "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "libc": [
- "musl"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
- "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
- "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
- "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
- "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
- "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
- "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/esbuild": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
- "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.12",
- "@esbuild/android-arm": "0.25.12",
- "@esbuild/android-arm64": "0.25.12",
- "@esbuild/android-x64": "0.25.12",
- "@esbuild/darwin-arm64": "0.25.12",
- "@esbuild/darwin-x64": "0.25.12",
- "@esbuild/freebsd-arm64": "0.25.12",
- "@esbuild/freebsd-x64": "0.25.12",
- "@esbuild/linux-arm": "0.25.12",
- "@esbuild/linux-arm64": "0.25.12",
- "@esbuild/linux-ia32": "0.25.12",
- "@esbuild/linux-loong64": "0.25.12",
- "@esbuild/linux-mips64el": "0.25.12",
- "@esbuild/linux-ppc64": "0.25.12",
- "@esbuild/linux-riscv64": "0.25.12",
- "@esbuild/linux-s390x": "0.25.12",
- "@esbuild/linux-x64": "0.25.12",
- "@esbuild/netbsd-arm64": "0.25.12",
- "@esbuild/netbsd-x64": "0.25.12",
- "@esbuild/openbsd-arm64": "0.25.12",
- "@esbuild/openbsd-x64": "0.25.12",
- "@esbuild/openharmony-arm64": "0.25.12",
- "@esbuild/sunos-x64": "0.25.12",
- "@esbuild/win32-arm64": "0.25.12",
- "@esbuild/win32-ia32": "0.25.12",
- "@esbuild/win32-x64": "0.25.12"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/nanoid": {
- "version": "3.3.15",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
- "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.16",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
- "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.12",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/rollup": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
- "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.9"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.62.2",
- "@rollup/rollup-android-arm64": "4.62.2",
- "@rollup/rollup-darwin-arm64": "4.62.2",
- "@rollup/rollup-darwin-x64": "4.62.2",
- "@rollup/rollup-freebsd-arm64": "4.62.2",
- "@rollup/rollup-freebsd-x64": "4.62.2",
- "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
- "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
- "@rollup/rollup-linux-arm64-gnu": "4.62.2",
- "@rollup/rollup-linux-arm64-musl": "4.62.2",
- "@rollup/rollup-linux-loong64-gnu": "4.62.2",
- "@rollup/rollup-linux-loong64-musl": "4.62.2",
- "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
- "@rollup/rollup-linux-ppc64-musl": "4.62.2",
- "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
- "@rollup/rollup-linux-riscv64-musl": "4.62.2",
- "@rollup/rollup-linux-s390x-gnu": "4.62.2",
- "@rollup/rollup-linux-x64-gnu": "4.62.2",
- "@rollup/rollup-linux-x64-musl": "4.62.2",
- "@rollup/rollup-openbsd-x64": "4.62.2",
- "@rollup/rollup-openharmony-arm64": "4.62.2",
- "@rollup/rollup-win32-arm64-msvc": "4.62.2",
- "@rollup/rollup-win32-ia32-msvc": "4.62.2",
- "@rollup/rollup-win32-x64-gnu": "4.62.2",
- "@rollup/rollup-win32-x64-msvc": "4.62.2",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/vite": {
- "version": "6.4.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
- "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.25.0",
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "jiti": ">=1.21.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- }
- }
-}
diff --git a/old/backend/dev-console/package.json b/old/backend/dev-console/package.json
deleted file mode 100644
index 65989f4..0000000
--- a/old/backend/dev-console/package.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "name": "haixun-dev-console",
- "private": true,
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview"
- },
- "devDependencies": {
- "vite": "^6.2.0"
- }
-}
\ No newline at end of file
diff --git a/old/backend/dev-console/vite.config.ts b/old/backend/dev-console/vite.config.ts
deleted file mode 100644
index c753b7c..0000000
--- a/old/backend/dev-console/vite.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from 'vite'
-
-export default defineConfig({
- server: {
- host: '127.0.0.1',
- port: 5173,
- proxy: {
- '/api': {
- target: 'http://127.0.0.1:8890',
- changeOrigin: true,
- },
- },
- },
-})
\ No newline at end of file
diff --git a/old/backend/docs/job-system-plan.md b/old/backend/docs/job-system-plan.md
deleted file mode 100644
index 99702db..0000000
--- a/old/backend/docs/job-system-plan.md
+++ /dev/null
@@ -1,381 +0,0 @@
-# Job 核心系統規劃
-
-## 目標
-
-建立一套通用 job system,讓任何長任務、流程任務、定時任務未來都能共用。Job 不只是背景任務,而是「有模板、有設定、有狀態、有進度、有取消能力、有重跑策略、有排程能力」的工作單元。
-
-## 核心設計
-
-採用:
-
-```text
-Mongo = job/template/run/history 的真相來源
-Redis = queue、distributed lock、schedule tick、短期 lease
-```
-
-```mermaid
-flowchart LR
- Api[GoAPI] --> Template[JobTemplate]
- Api --> JobRun[JobRunMongo]
- JobRun --> RedisQueue[RedisQueue]
- Scheduler[SchedulerTick] --> RedisQueue
- Worker[Worker] --> RedisQueue
- Worker --> JobRun
- Worker --> Step[JobStep]
-```
-
-## 核心概念
-
-### JobTemplate
-
-Template 定義「這種 job 要怎麼做」。例如:
-
-```text
-demo_long_task
-external_worker_task
-scheduled_report
-multi_step_pipeline
-```
-
-Template 要回答:
-
-- 這個 job 的輸入 payload schema 是什麼
-- 有哪些 steps
-- 最終狀態是什麼
-- 可不可以重複執行
-- 是否允許同 account / 同 target 同時跑
-- retry policy 是什麼
-- timeout 是多少
-- 是否可被排程
-- 是否支援取消,以及取消時 worker 要如何收斂
-
-### JobRun
-
-JobRun 是每一次執行實例。它引用 template,保存當次 payload、狀態、進度、結果、錯誤與執行歷史。
-
-### JobSchedule
-
-JobSchedule 是「何時建立 JobRun」。支援:
-
-- cron
-- enabled / disabled
-- timezone
-- payload template
-- target scope,例如 user/account/system
-- nextRunAt / lastRunAt
-
-### JobFlow
-
-Flow 是多步驟流程。第一版不用做完整 DAG,先支援線性 steps:
-
-```text
-multi_step_pipeline:
- 1. prepare
- 2. execute
- 3. finalize
-```
-
-之後再擴成 DAG 或 conditional branch。
-
-## Mongo Collections
-
-### `job_templates`
-
-```json
-{
- "_id": "...",
- "type": "demo_long_task",
- "version": 1,
- "name": "示範長任務",
- "description": "展示 job template、進度、取消、重跑與排程能力",
- "enabled": true,
- "repeatable": true,
- "concurrencyPolicy": "reject_same_scope",
- "dedupeKeys": ["scope_id", "target"],
- "timeoutSeconds": 600,
- "cancelPolicy": {
- "supported": true,
- "mode": "cooperative",
- "graceSeconds": 30
- },
- "retryPolicy": {
- "maxAttempts": 2,
- "backoffSeconds": [30, 120]
- },
- "steps": [
- { "id": "prepare", "name": "準備資料", "workerType": "go", "timeoutSeconds": 60, "cancelable": true },
- { "id": "execute", "name": "執行任務", "workerType": "go", "timeoutSeconds": 300, "cancelable": true },
- { "id": "finalize", "name": "整理結果", "workerType": "go", "timeoutSeconds": 30, "cancelable": false }
- ],
- "createAt": 0,
- "updateAt": 0
-}
-```
-
-### `job_runs`
-
-```json
-{
- "_id": "...",
- "templateType": "demo_long_task",
- "templateVersion": 1,
- "scope": "user",
- "scopeId": "user_123",
- "status": "pending",
- "phase": "prepare",
- "payload": {},
- "progress": {
- "summary": "等待 worker 執行",
- "percentage": 20,
- "steps": []
- },
- "result": null,
- "error": null,
- "attempt": 0,
- "maxAttempts": 2,
- "lockedBy": null,
- "lockedUntil": null,
- "cancelRequestedAt": null,
- "cancelReason": null,
- "scheduledAt": null,
- "startedAt": null,
- "completedAt": null,
- "createAt": 0,
- "updateAt": 0
-}
-```
-
-### `job_schedules`
-
-```json
-{
- "_id": "...",
- "templateType": "demo_long_task",
- "scope": "user",
- "scopeId": "user_123",
- "enabled": true,
- "cron": "0 9 * * *",
- "timezone": "Asia/Taipei",
- "payloadTemplate": {},
- "lastRunAt": null,
- "nextRunAt": 0,
- "createAt": 0,
- "updateAt": 0
-}
-```
-
-### `job_events`
-
-用來觀察與 audit:
-
-```json
-{
- "_id": "...",
- "jobId": "...",
- "type": "status_changed",
- "from": "pending",
- "to": "running",
- "message": "worker claimed job",
- "metadata": {},
- "createAt": 0
-}
-```
-
-## Status Model
-
-```text
-pending = 已建立,等待 queue
-queued = 已推進 Redis queue
-running = worker 執行中
-waiting_worker = 等外部 worker 回寫
-cancel_requested = 使用者已要求取消,等待 worker cooperative stop
-succeeded = 成功完成
-failed = 最終失敗
-cancelled = 使用者取消
-expired = lock/timeout 過期後無法恢復
-```
-
-Step status:
-
-```text
-pending | running | succeeded | failed | skipped | cancelled
-```
-
-## 取消語意
-
-取消是第一版必做能力,採 cooperative cancellation:
-
-```mermaid
-flowchart LR
- User[User] --> ApiCancel[CancelAPI]
- ApiCancel --> Run[JobRun cancel_requested]
- Run --> RedisCancel[RedisCancelSignal]
- Worker[Worker] -->|"poll cancel flag"| Run
- Worker --> Stop[StopCurrentStep]
- Stop --> Final[JobRun cancelled]
-```
-
-規則:
-
-- `pending` / `queued`:取消後直接變 `cancelled`,並盡量從 Redis queue 移除;若無法移除,worker claim 時必須檢查狀態並跳過。
-- `running`:狀態改為 `cancel_requested`,寫入 `cancelRequestedAt` / `cancelReason`,worker 必須在 step 間或長任務 checkpoint 檢查取消旗標。
-- `waiting_worker`:狀態改為 `cancel_requested`,同時寫 Redis cancel signal;外部 worker 回寫前要檢查 job 狀態。
-- `succeeded` / `failed` / `cancelled` / `expired`:不可取消,回傳 ResourceInvalidState。
-- worker 收到取消後呼叫 `AcknowledgeCancel(jobId, workerId)`,釋放 lock,寫入 `job_events`,狀態變 `cancelled`。
-- 若 `cancel_requested` 超過 template 的 `cancelPolicy.graceSeconds`,scheduler/reaper 可標記為 `cancelled` 或 `expired`,第一版建議標記 `cancelled` 並記錄 timeout event。
-
-## 狀態與 Lock 安全規則
-
-第一版已把最容易出問題的 race condition 收斂在 repository / usecase:
-
-- `ClaimNext` 只能從 `pending` / `queued` conditional update 成 `running`。如果 API 同時取消,Mongo update 會被拒絕。
-- `RequestCancel` 只能從 cancellable 狀態 conditional update;`pending` / `queued` 直接變 `cancelled`,`running` / `waiting_worker` 變 `cancel_requested`。
-- `CompleteRun` / `FailRun` / `UpdateProgress` 必須帶 `workerID`,並且只能更新 `lockedBy == workerID` 的 job。
-- Redis `jobs:lock:` 的 value 是 `workerID`;`ReleaseLock` / `RefreshLock` 使用 owner check,避免舊 worker 誤刪新 worker 的 lock。
-- Worker 長任務要定期 heartbeat,呼叫 `RefreshRunLock(jobId, workerID, ttlSeconds)`。自訂 step handler 可用 `StepContext.Heartbeat`。
-
-之後新增狀態轉移時,不要直接使用裸 `Update`;若是生命週期狀態,應新增明確的 guarded repository 方法或使用現有 conditional update。
-
-## Redis Keys
-
-```text
-jobs:queue: # list 或 stream,worker 消費
-jobs:lock: # lease lock
-jobs:scheduler:lock # scheduler singleton lock
-jobs:dedupe:: # 防止同 scope 重複跑
-jobs:cancel: # cancel signal,worker checkpoint 讀取
-```
-
-第一版建議用 Redis List 即可,之後需要 ack/replay 再升級 Redis Streams。
-
-## API 規劃
-
-```text
-GET /api/v1/job/templates
-GET /api/v1/job/templates/:type
-PUT /api/v1/job/templates/:type
-
-POST /api/v1/jobs
-GET /api/v1/jobs/:id
-GET /api/v1/jobs?page=1&pageSize=20
-POST /api/v1/jobs/:id/cancel
-POST /api/v1/jobs/:id/retry
-
-GET /api/v1/job/schedules?page=1&pageSize=20
-POST /api/v1/job/schedules
-PUT /api/v1/job/schedules/:id
-POST /api/v1/job/schedules/:id/enable
-POST /api/v1/job/schedules/:id/disable
-```
-
-所有列表回應使用目前標準:
-
-```json
-{
- "code": 102000,
- "message": "SUCCESS",
- "data": {
- "pagination": {
- "total": 42,
- "page": 1,
- "pageSize": 10,
- "totalPages": 5
- },
- "list": []
- }
-}
-```
-
-## 分層規劃
-
-```text
-internal/model/job/
- domain/entity/template.go
- domain/entity/run.go
- domain/entity/schedule.go
- domain/entity/event.go
- domain/enum/status.go
- domain/repository/*.go
- domain/usecase/*.go
- repository/mongo_*.go
- repository/redis_queue.go
- usecase/template_usecase.go
- usecase/run_usecase.go
- usecase/schedule_usecase.go
- usecase/progress_usecase.go
- usecase/worker_usecase.go
-
-internal/logic/job/
- create_job_logic.go
- get_job_logic.go
- list_jobs_logic.go
- cancel_job_logic.go
- retry_job_logic.go
- template_logic.go
- schedule_logic.go
-
-internal/worker/job/
- runner.go
- scheduler.go
- dispatcher.go
-```
-
-## Template 執行規則
-
-### 可不可以重複做
-
-由 `repeatable` 與 `concurrencyPolicy` 控制:
-
-```text
-repeatable=false
- 同 dedupe key 完成過就不再建立
-
-repeatable=true + reject_same_scope
- 已有 running/pending job 時拒絕建立
-
-repeatable=true + allow_parallel
- 允許平行跑
-
-repeatable=true + replace_existing
- 取消舊 job,建立新 job
-```
-
-### 最終狀態
-
-Template 可定義成功條件:
-
-```text
-successWhen = all_steps_succeeded
-```
-
-第一版只支援 `all_steps_succeeded`。之後再加 `any_step_succeeded` 或 conditional flow。
-
-### 取消能力
-
-Template 用 `cancelPolicy` 控制取消:
-
-```text
-supported=false
- API 不允許取消此 job
-
-mode=cooperative
- worker checkpoint 檢查取消旗標後收斂
-
-graceSeconds=30
- cancel_requested 超過 grace 後由 reaper 收斂
-```
-
-Step 用 `cancelable` 控制目前步驟是否可立即停止。若目前 step 不可取消,worker 需要在 step 結束後停止後續 steps,最後狀態仍為 `cancelled`。
-
-## 第一版建議實作順序
-
-1. 建 `model/job` 的 enum/entity/repository interface。
-2. 實作 Mongo repositories:template/run/schedule/event。
-3. 實作 Redis queue/lock repository。
-4. 實作 `CreateRun`:讀 template、檢查 repeat/concurrency、建立 run、push queue。
-5. 實作 `ClaimNext`:worker 從 Redis 取 job,Mongo 設 lock/status。
-6. 實作 `RequestCancel`:狀態轉 `cancel_requested` 或直接 `cancelled`,寫 Redis cancel signal 與 event。
-7. 實作 `AcknowledgeCancel`:worker 收斂後釋放 lock,狀態轉 `cancelled`。
-8. 實作 `UpdateProgress`、`Complete`、`Fail`、`Retry`。
-9. 實作 schedule tick:掃 `job_schedules.nextRunAt <= now`,建立 JobRun。
-10. 實作 API 與文件。
diff --git a/old/backend/docs/threads-automation-gap-plan.md b/old/backend/docs/threads-automation-gap-plan.md
deleted file mode 100644
index 23cd0b9..0000000
--- a/old/backend/docs/threads-automation-gap-plan.md
+++ /dev/null
@@ -1,133 +0,0 @@
-# Threads 自動營運補功能計畫
-
-## 目前狀態
-
-P1/P2 已落地到後端 API、worker 與正式前端:
-
-- 自動補庫存:`publish_inventory_policies` + `refill-publish-inventory` job template/worker。
-- 智慧時段:`GET /publish-slot-insights` 從 publish health / checkpoints 聚合推薦時段。
-- 草稿批量排程:Persona 與 Copy Mission 草稿可批量排到 `publish_queue`,草稿回寫 `scheduled + publish_queue_id`。
-- Retry / 告警 / 漏發:`publish_queue_events` 記錄狀態轉移;sweeper 會標記 missed;failed/cancelled/missed 可 retry。
-- 頻率護欄:`publish_guard_policies` 支援每日上限、最小間隔、連續失敗 pause / resume。
-- P2:內容日曆 date range query、多帳號 dashboard summary、style preset 語調庫、OAuth/API diagnostics。
-
-## 目標
-
-把現有「海巡找題、AI 文案、手動排程、成效追蹤」串成自動營運閉環,讓巡樓 Console 不只是一個排程工具,而是可持續補庫存、控制風險、追蹤成效的 Threads 工作台。
-
-## P1:自動補庫存
-
-### 功能
-
-- 每個 Threads account 可設定每日/每週目標篇數。
-- 可設定 weekday/time slots 與 timezone;timezone 僅用於 cron/排程解讀,儲存仍用 unix nanoseconds UTC。
-- 指定內容來源:persona、copy mission、brand scan posts、manual seed。
-- 當 `publish_queue` 未來 N 天庫存低於門檻時,自動建立 copy generation job,完成後排入 queue。
-
-### 後端草案
-
-- 新增 model:`internal/model/publish_inventory/`。
-- 新增設定 entity:
- - `account_id`
- - `target_daily_count`
- - `low_stock_threshold`
- - `lookahead_days`
- - `timezone`
- - `slots`
- - `source_refs`
- - `enabled`
-- 新增 job step:`refill_publish_inventory`。
-- 補 API:
- - `GET /api/v1/threads-accounts/:accountId/publish-inventory-policy`
- - `PUT /api/v1/threads-accounts/:accountId/publish-inventory-policy`
- - `POST /api/v1/threads-accounts/:accountId/publish-inventory/refill-jobs`
-
-## P1:智慧時段
-
-### 功能
-
-- 第一版以規則為主:帳號的 weekday/time slots + 最小間隔。
-- 用 publish health 的 1h/24h/7d checkpoints 回填每個 slot 的平均互動。
-- 前端顯示「推薦時段」與「低成效時段」。
-
-### 後端草案
-
-- 新增 summary usecase,從 `publish_analytics` 聚合 account slot performance。
-- 補 API:
- - `GET /api/v1/threads-accounts/:accountId/publish-slot-insights`
-- 回傳:
- - `slots[]`
- - `avg_likes_1h/24h/7d`
- - `avg_replies_1h/24h/7d`
- - `sample_size`
- - `recommendation`
-
-## P1:草稿批量排程
-
-### 功能
-
-- 使用者在 copy drafts 中選多篇,一次排到未來 slots。
-- 支援根據智慧時段自動分配 `scheduled_at`。
-- draft 排入 queue 後狀態改為 `scheduled`,保留 queue item id。
-
-### 後端草案
-
-- 補 API:
- - `POST /api/v1/personas/:personaId/copy-drafts/schedule`
- - `POST /api/v1/personas/:personaId/copy-missions/:missionId/copy-drafts/schedule`
-- request:
- - `account_id`
- - `draft_ids`
- - `start_at`
- - `timezone`
- - `slots`
- - `mode`: `manual` / `recommended`
-
-## P1:發文 retry / 告警 / 漏發偵測
-
-### 功能
-
-- failed queue item 可重試,保留失敗歷史。
-- sweeper 偵測到超過 grace period 的 scheduled item 未處理,標記為 missed 或重新 enqueue。
-- 連續失敗超過門檻時 pause account publish,避免 token 或 rate limit 問題擴大。
-
-### 後端草案
-
-- `publish_queue_events` 記錄 queue 狀態轉移與錯誤分類。
-- `retry` 使用 guarded update,只允許 `failed/cancelled` 回到 `scheduled`。
-- 補 API:
- - `POST /api/v1/threads-accounts/:accountId/publish-queue/:queueId/retry`
- - `GET /api/v1/threads-accounts/:accountId/publish-queue/:queueId/events`
- - `GET /api/v1/threads-accounts/:accountId/publish-alerts`
-
-## P1:頻率護欄
-
-### 功能
-
-- 每帳號每日上限。
-- 最小發文間隔。
-- 連續失敗自動 pause。
-- 手動解除 pause。
-
-### 後端草案
-
-- 擴充 Threads account connection prefs 或新增 `publish_guard_policy`。
-- sweeper dispatch 前檢查 policy;違反則延後排程,不直接發文。
-- 補 API:
- - `GET /api/v1/threads-accounts/:accountId/publish-guard-policy`
- - `PUT /api/v1/threads-accounts/:accountId/publish-guard-policy`
- - `POST /api/v1/threads-accounts/:accountId/publish-guard/resume`
-
-## P2
-
-- 內容日曆:補 queue date range query,前端做週/月視圖。
-- 多帳號 dashboard:補跨帳號 summary endpoint,避免前端 N+1 requests。
-- 語調庫:把 `style_profile`、CTA、禁用詞、品牌口吻抽成 reusable preset。
-- OAuth/API 診斷:把 smoke test、token expiry、permission scope 做成標準診斷報告。
-
-## 驗證策略
-
-- 所有列表維持 `page/pageSize` 與 `pagination/list`。
-- 所有時間欄位寫入 unix nanoseconds。
-- queue/job 狀態轉移都使用 guarded update。
-- 長任務必須 heartbeat,取消走既有 cooperative cancel 語意。
diff --git a/old/backend/etc/gateway.prod.yaml b/old/backend/etc/gateway.prod.yaml
deleted file mode 100644
index 37cb84d..0000000
--- a/old/backend/etc/gateway.prod.yaml
+++ /dev/null
@@ -1,72 +0,0 @@
-Name: haixun-backend
-Host: 0.0.0.0
-Port: 8890
-Timeout: 120000
-
-Mongo:
- URI: ${HAIXUN_MONGO_URI}
- Database: ${HAIXUN_MONGO_DB}
- TimeoutSeconds: 10
-
-Redis:
- Addr: ${HAIXUN_REDIS_ADDR}
- Password: ${HAIXUN_REDIS_PASSWORD}
- DB: 0
-
-Auth:
- AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
- RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
- AccessExpireSeconds: 900
- RefreshExpireSeconds: 2592000
- DevHeaderFallback: false
-
-Secrets:
- EncryptionKey: ${HAIXUN_SECRETS_KEY}
-
-Storage:
- S3:
- Endpoint: ${HAIXUN_STORAGE_S3_ENDPOINT}
- PublicBaseURL: ${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL}
- Region: ${HAIXUN_STORAGE_S3_REGION}
- Bucket: ${HAIXUN_STORAGE_S3_BUCKET}
- AccessKey: ${HAIXUN_STORAGE_S3_ACCESS_KEY}
- SecretKey: ${HAIXUN_STORAGE_S3_SECRET_KEY}
- UsePathStyle: ${HAIXUN_STORAGE_S3_USE_PATH_STYLE}
-
-SMTP:
- Host: ${HAIXUN_SMTP_HOST}
- Port: ${HAIXUN_SMTP_PORT}
- Username: ${HAIXUN_SMTP_USERNAME}
- Password: ${HAIXUN_SMTP_PASSWORD}
- From: ${HAIXUN_SMTP_FROM}
-
-InternalWorker:
- Secret: ${HAIXUN_WORKER_SECRET}
-
-JobWorker:
- Enabled: false
- WorkerType: disabled
-
-JobScheduler:
- Enabled: false
- IntervalSeconds: 60
-
-JobReaper:
- Enabled: false
- IntervalSeconds: 30
-
-ThreadsOAuth:
- AppID: "${THREADS_APP_ID}"
- AppSecret: "${THREADS_APP_SECRET}"
- RedirectURI: "${THREADS_REDIRECT_URI}"
- SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
-
-ThreadsTokenRefresh:
- Enabled: false
- IntervalSeconds: 3600
- LeadDays: 7
-
-ThreadsPublishQueue:
- Enabled: false
- IntervalSeconds: 60
- BatchSize: 20
diff --git a/old/backend/etc/gateway.worker.prod.yaml b/old/backend/etc/gateway.worker.prod.yaml
deleted file mode 100644
index bd40c65..0000000
--- a/old/backend/etc/gateway.worker.prod.yaml
+++ /dev/null
@@ -1,56 +0,0 @@
-Name: haixun-worker
-Host: 0.0.0.0
-Port: 8891
-Timeout: 120000
-
-Mongo:
- URI: ${HAIXUN_MONGO_URI}
- Database: ${HAIXUN_MONGO_DB}
- TimeoutSeconds: 10
-
-Redis:
- Addr: ${HAIXUN_REDIS_ADDR}
- Password: ${HAIXUN_REDIS_PASSWORD}
- DB: 0
-
-Auth:
- AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
- RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
- AccessExpireSeconds: 900
- RefreshExpireSeconds: 2592000
- DevHeaderFallback: false
-
-Secrets:
- EncryptionKey: ${HAIXUN_SECRETS_KEY}
-
-InternalWorker:
- Secret: ${HAIXUN_WORKER_SECRET}
-
-JobWorker:
- Enabled: ${HAIXUN_JOB_WORKER_ENABLED}
- WorkerType: ${HAIXUN_JOB_WORKER_TYPE}
- WorkerID: ${HAIXUN_JOB_WORKER_ID}
-
-JobScheduler:
- Enabled: ${HAIXUN_JOB_SCHEDULER_ENABLED}
- IntervalSeconds: ${HAIXUN_JOB_SCHEDULER_INTERVAL_SECONDS}
-
-JobReaper:
- Enabled: ${HAIXUN_JOB_REAPER_ENABLED}
- IntervalSeconds: ${HAIXUN_JOB_REAPER_INTERVAL_SECONDS}
-
-ThreadsOAuth:
- AppID: "${THREADS_APP_ID}"
- AppSecret: "${THREADS_APP_SECRET}"
- RedirectURI: "${THREADS_REDIRECT_URI}"
- SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
-
-ThreadsTokenRefresh:
- Enabled: ${HAIXUN_THREADS_TOKEN_REFRESH_ENABLED}
- IntervalSeconds: ${HAIXUN_THREADS_TOKEN_REFRESH_INTERVAL_SECONDS}
- LeadDays: ${HAIXUN_THREADS_TOKEN_REFRESH_LEAD_DAYS}
-
-ThreadsPublishQueue:
- Enabled: ${HAIXUN_THREADS_PUBLISH_QUEUE_ENABLED}
- IntervalSeconds: ${HAIXUN_THREADS_PUBLISH_QUEUE_INTERVAL_SECONDS}
- BatchSize: ${HAIXUN_THREADS_PUBLISH_QUEUE_BATCH_SIZE}
diff --git a/old/backend/etc/gateway.worker.yaml b/old/backend/etc/gateway.worker.yaml
deleted file mode 100644
index af7dcc2..0000000
--- a/old/backend/etc/gateway.worker.yaml
+++ /dev/null
@@ -1,51 +0,0 @@
-Name: haixun-worker
-Host: 0.0.0.0
-Port: 8891
-Timeout: 120000
-
-# 本機開發 worker 設定(go worker)。搭配 `make dev-infra` 的 Mongo/Redis。
-Mongo:
- URI: ${HAIXUN_MONGO_URI}
- Database: ${HAIXUN_MONGO_DB}
- TimeoutSeconds: 10
-
-Redis:
- Addr: ${HAIXUN_REDIS_ADDR}
- Password: ${HAIXUN_REDIS_PASSWORD}
- DB: 0
-
-Auth:
- AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
- RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
- AccessExpireSeconds: 900
- RefreshExpireSeconds: 2592000
- DevHeaderFallback: false
-
-Secrets:
- EncryptionKey: ${HAIXUN_SECRETS_KEY}
-
-InternalWorker:
- Secret: ${HAIXUN_WORKER_SECRET}
-
-JobWorker:
- Enabled: true
- WorkerType: ${HAIXUN_JOB_WORKER_TYPE}
- WorkerID: ${HAIXUN_JOB_WORKER_ID}
-
-JobScheduler:
- Enabled: false
- IntervalSeconds: 60
-
-JobReaper:
- Enabled: false
- IntervalSeconds: 30
-
-ThreadsTokenRefresh:
- Enabled: false
- IntervalSeconds: 3600
- LeadDays: 7
-
-ThreadsPublishQueue:
- Enabled: false
- IntervalSeconds: 60
- BatchSize: 20
diff --git a/old/backend/etc/gateway.yaml b/old/backend/etc/gateway.yaml
deleted file mode 100644
index 1bd4f60..0000000
--- a/old/backend/etc/gateway.yaml
+++ /dev/null
@@ -1,83 +0,0 @@
-Name: haixun-backend
-Host: 0.0.0.0
-Port: 8890
-Timeout: 120000
-
-# 本機開發設定。預設搭配 `make dev-infra`(infra/docker-compose.yml,讀 deploy/.env.dev)跑的 Mongo/Redis。
-# 帳密對應 deploy/.env.dev 的值;若改 .env.dev 密碼,這裡不需動(${} 會重讀)。
-Mongo:
- URI: ${HAIXUN_MONGO_URI}
- Database: ${HAIXUN_MONGO_DB}
- TimeoutSeconds: 10
-
-Redis:
- Addr: ${HAIXUN_REDIS_ADDR}
- Password: ${HAIXUN_REDIS_PASSWORD}
- DB: 0
-
-Auth:
- AccessSecret: ${HAIXUN_JWT_ACCESS_SECRET}
- RefreshSecret: ${HAIXUN_JWT_REFRESH_SECRET}
- AccessExpireSeconds: 900
- RefreshExpireSeconds: 2592000
- # 僅本機開發開啟:允許用 X-Tenant-ID / X-UID header 模擬登入。正式環境必須為 false。
- DevHeaderFallback: true
-
-Secrets:
- EncryptionKey: ${HAIXUN_SECRETS_KEY}
-
-Storage:
- S3:
- Endpoint: ${HAIXUN_STORAGE_S3_ENDPOINT}
- PublicBaseURL: ${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL}
- Region: ${HAIXUN_STORAGE_S3_REGION}
- Bucket: ${HAIXUN_STORAGE_S3_BUCKET}
- AccessKey: ${HAIXUN_STORAGE_S3_ACCESS_KEY}
- SecretKey: ${HAIXUN_STORAGE_S3_SECRET_KEY}
- UsePathStyle: ${HAIXUN_STORAGE_S3_USE_PATH_STYLE}
-
-SMTP:
- Host: ${HAIXUN_SMTP_HOST}
- Port: ${HAIXUN_SMTP_PORT}
- Username: ${HAIXUN_SMTP_USERNAME}
- Password: ${HAIXUN_SMTP_PASSWORD}
- From: ${HAIXUN_SMTP_FROM}
-
-# 封測 admin 建立帳號權限 seed。之後開放公開註冊時可關閉。
-Permission:
- SeedAdminRegister: true
-
-InternalWorker:
- Secret: ${HAIXUN_WORKER_SECRET}
-
-JobWorker:
- Enabled: false
- WorkerType: disabled
-
-JobScheduler:
- Enabled: true
- IntervalSeconds: 60
-
-JobReaper:
- Enabled: true
- IntervalSeconds: 30
-
-# Meta Threads OAuth(App Dashboard → 用戶端 OAuth 設定須登記 Redirect URI)
-ThreadsOAuth:
- AppID: "${THREADS_APP_ID}"
- AppSecret: "${THREADS_APP_SECRET}"
- RedirectURI: "${THREADS_REDIRECT_URI}"
- # OAuth 完成後瀏覽器導回(現在指向 https://threads-tool.30cm.net )
- SuccessRedirect: "${THREADS_OAUTH_SUCCESS_REDIRECT}"
-
-# 系統任務:在 token 到期前自動 enqueue refresh-threads-token job
-ThreadsTokenRefresh:
- Enabled: true
- IntervalSeconds: 3600
- LeadDays: 7
-
-# 排程發文佇列 sweeper(掃描 scheduled_at 到期項目並 PublishText)
-ThreadsPublishQueue:
- Enabled: true
- IntervalSeconds: 60
- BatchSize: 20
diff --git a/old/backend/gateway.go b/old/backend/gateway.go
deleted file mode 100644
index 130f7f7..0000000
--- a/old/backend/gateway.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package main
-
-import (
- "context"
- "flag"
- "fmt"
-
- "haixun-backend/internal/bootstrap"
- "haixun-backend/internal/config"
- "haixun-backend/internal/handler"
- "haixun-backend/internal/svc"
-
- "github.com/zeromicro/go-zero/core/conf"
- "github.com/zeromicro/go-zero/rest"
-)
-
-var configFile = flag.String("f", "etc/gateway.yaml", "config file")
-
-func main() {
- flag.Parse()
-
- bootstrap.LoadInfraEnv()
-
- var c config.Config
- conf.MustLoad(*configFile, &c, conf.UseEnv())
-
- server := rest.MustNewServer(c.RestConf)
- defer server.Stop()
-
- sc := svc.NewServiceContext(c)
- defer sc.Close(context.Background())
-
- handler.RegisterHandlers(server, sc)
- handler.RegisterExtraHandlers(server, sc)
-
- fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port)
- server.Start()
-}
diff --git a/old/backend/generate/api/ai.api b/old/backend/generate/api/ai.api
deleted file mode 100644
index fb4e737..0000000
--- a/old/backend/generate/api/ai.api
+++ /dev/null
@@ -1,83 +0,0 @@
-syntax = "v1"
-
-type (
- AIMessage {
- Role string `json:"role" validate:"required,oneof=system user assistant"` // 訊息角色
- Content string `json:"content" validate:"required"` // 訊息內容
- }
- AIChatReq {
- Provider string `json:"provider" validate:"required,oneof=opencode-go xai"` // AI provider
- Model string `json:"model" validate:"required"` // 模型 ID
- System string `json:"system,optional"` // system prompt
- Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
- Temperature *float64 `json:"temperature,optional"` // 溫度
- MaxTokens *int `json:"max_tokens,optional"` // 最大輸出 token
- }
- AIProviderOption {
- ID string `json:"id"`
- Label string `json:"label"`
- Streams bool `json:"streams"`
- }
- AIProvidersData {
- Providers []AIProviderOption `json:"providers"`
- }
- AIProviderPath {
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` // 要查模型的 provider
- }
- AIProviderModelsData {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,optional"` // 拉 models 失敗時的摘要
- }
- AIChatData {
- Text string `json:"text"`
- FinishReason string `json:"finish_reason,optional"`
- }
- IslanderChatReq {
- Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
- Context string `json:"context,optional"` // 目前頁面與站內導覽快照
- }
-)
-
-@server (
- group: ai
- prefix: /api/v1/ai
- tags: "AI - Provider Streaming"
- summary: "Public AI provider catalog"
-)
-service gateway {
- @handler listAiProviders
- get /providers returns (AIProvidersData)
-}
-
-@server (
- group: ai
- prefix: /api/v1/ai
- middleware: MemberAuth
- tags: "AI - Provider Streaming (member)"
- summary: "Chat/stream/models; member JWT via X-Member-Authorization; provider API key via Authorization"
-)
-service gateway {
- @handler listAiProviderModels
- post /providers/:provider/models (AIProviderPath) returns (AIProviderModelsData)
-
- @handler chat
- post /chat (AIChatReq) returns (AIChatData)
-
- @handler chatStream
- post /chat/stream (AIChatReq)
-}
-
-@server (
- group: ai
- prefix: /api/v1/ai
- middleware: AuthJWT
- tags: "AI - Islander Guide"
- summary: "Floating islander chat; member JWT via Authorization; AI key from member settings"
-)
-service gateway {
- @handler islanderChatStream
- post /islander/chat/stream (IslanderChatReq)
-}
\ No newline at end of file
diff --git a/old/backend/generate/api/auth.api b/old/backend/generate/api/auth.api
deleted file mode 100644
index 2382124..0000000
--- a/old/backend/generate/api/auth.api
+++ /dev/null
@@ -1,80 +0,0 @@
-syntax = "v1"
-
-type (
- AuthRegisterReq {
- TenantID string `json:"tenant_id" validate:"required"`
- Email string `json:"email" validate:"required,email"`
- Password string `json:"password" validate:"required,min=8,max=128"`
- DisplayName string `json:"display_name,optional"`
- Language string `json:"language,optional"`
- }
-
- AuthLoginReq {
- TenantID string `json:"tenant_id" validate:"required"`
- Email string `json:"email" validate:"required,email"`
- Password string `json:"password" validate:"required,min=8,max=128"`
- }
-
- AuthRefreshReq {
- RefreshToken string `json:"refresh_token" validate:"required"`
- }
-
- AuthTokenData {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
- ExpiresIn int64 `json:"expires_in"`
- UID string `json:"uid"`
- TokenType string `json:"token_type"`
- }
-
- LogoutData {
- OK bool `json:"ok"`
- }
-
- AuthVerifyEmailReq {
- Code string `json:"code" validate:"required,len=6"`
- }
-
- AuthVerifyEmailData {
- EmailVerified bool `json:"email_verified"`
- }
-
- AuthResendVerificationData {
- Sent bool `json:"sent"`
- }
-)
-
-@server(
- group: auth
- prefix: /api/v1/auth
- tags: "Auth"
- summary: "Native member auth and JWT token endpoints"
-)
-service gateway {
- @handler login
- post /login (AuthLoginReq) returns (AuthTokenData)
-
- @handler refresh
- post /refresh (AuthRefreshReq) returns (AuthTokenData)
-}
-
-@server(
- group: auth
- prefix: /api/v1/auth
- middleware: AuthJWT
- tags: "Auth"
- summary: "Authenticated auth management endpoints"
-)
-service gateway {
- @handler register
- post /register (AuthRegisterReq) returns (AuthTokenData)
-
- @handler logout
- post /logout returns (LogoutData)
-
- @handler verifyEmail
- post /verify-email (AuthVerifyEmailReq) returns (AuthVerifyEmailData)
-
- @handler resendVerificationEmail
- post /resend-verification-email returns (AuthResendVerificationData)
-}
diff --git a/old/backend/generate/api/brand.api b/old/backend/generate/api/brand.api
deleted file mode 100644
index d4658e1..0000000
--- a/old/backend/generate/api/brand.api
+++ /dev/null
@@ -1,465 +0,0 @@
-syntax = "v1"
-
-type (
- ResearchItemData {
- Title string `json:"title,omitempty"`
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- Query string `json:"query,omitempty"`
- }
-
- ResearchMapData {
- AudienceSummary string `json:"audience_summary,omitempty"`
- ContentGoal string `json:"content_goal,omitempty"`
- Questions []string `json:"questions,omitempty"`
- Pillars []string `json:"pillars,omitempty"`
- Exclusions []string `json:"exclusions,omitempty"`
- ResearchItems []ResearchItemData `json:"research_items,omitempty"`
- ExpandStrategy string `json:"expand_strategy,omitempty"`
- PatrolKeywords []string `json:"patrol_keywords,omitempty"`
- }
-
- KnowledgeGraphEvidenceData {
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- Query string `json:"query,omitempty"`
- }
-
- BraveSourceData {
- Query string `json:"query,omitempty"`
- Title string `json:"title,omitempty"`
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- }
-
- BrandProductData {
- ID string `json:"id"`
- Label string `json:"label"`
- ProductContext string `json:"product_context"`
- PlacementURL string `json:"placement_url,omitempty"`
- MatchTags []string `json:"match_tags,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- BrandData {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- TopicName string `json:"topic_name,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- Brief string `json:"brief,omitempty"`
- ProductBrief string `json:"product_brief,omitempty"`
- ProductContext string `json:"product_context,omitempty"`
- ProductID string `json:"product_id,omitempty"`
- Products []BrandProductData `json:"products,omitempty"`
- TargetAudience string `json:"target_audience,omitempty"`
- Goals string `json:"goals,omitempty"`
- ResearchMap ResearchMapData `json:"research_map,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ListBrandProductsData {
- List []BrandProductData `json:"list"`
- }
-
- CreateBrandProductReq {
- Label string `json:"label" validate:"required"`
- ProductContext string `json:"product_context" validate:"required"`
- PlacementURL string `json:"placement_url,optional"`
- MatchTags []string `json:"match_tags,optional"`
- }
-
- UpdateBrandProductReq {
- Label *string `json:"label,optional"`
- ProductContext *string `json:"product_context,optional"`
- PlacementURL string `json:"placement_url,optional"`
- MatchTags []string `json:"match_tags,optional"`
- }
-
- BrandProductPath {
- ID string `path:"id" validate:"required"`
- ProductID string `path:"productId" validate:"required"`
- }
-
- CreateBrandProductHandlerReq {
- BrandPath
- CreateBrandProductReq
- }
-
- UpdateBrandProductHandlerReq {
- BrandProductPath
- UpdateBrandProductReq
- }
-
- ListBrandsData {
- List []BrandData `json:"list"`
- }
-
- CreateBrandReq {
- DisplayName string `json:"display_name,optional"`
- }
-
- BrandPath {
- ID string `path:"id" validate:"required"`
- }
-
- UpdateBrandReq {
- DisplayName *string `json:"display_name,optional"`
- TopicName *string `json:"topic_name,optional"`
- SeedQuery *string `json:"seed_query,optional"`
- Brief *string `json:"brief,optional"`
- ProductBrief *string `json:"product_brief,optional"`
- ProductContext *string `json:"product_context,optional"`
- ProductID *string `json:"product_id,optional"`
- TargetAudience *string `json:"target_audience,optional"`
- Goals *string `json:"goals,optional"`
- AudienceSummary *string `json:"audience_summary,optional"`
- ContentGoal *string `json:"content_goal,optional"`
- Questions []string `json:"questions,optional"`
- Pillars []string `json:"pillars,optional"`
- Exclusions []string `json:"exclusions,optional"`
- PatrolKeywords []string `json:"patrol_keywords,optional"`
- }
-
- KnowledgeGraphNodeData {
- ID string `json:"id"`
- Label string `json:"label"`
- NodeKind string `json:"node_kind"`
- Type string `json:"type"`
- Layer int `json:"layer"`
- Relation string `json:"relation,omitempty"`
- PlacementValue string `json:"placement_value,omitempty"`
- ProductFitScore int `json:"product_fit_score"`
- SelectedForScan bool `json:"selected_for_scan"`
- RelevanceTags []string `json:"relevance_tags"`
- RecencyTags []string `json:"recency_tags"`
- Evidence []KnowledgeGraphEvidenceData `json:"evidence,omitempty"`
- }
-
- KnowledgeGraphEdgeData {
- From string `json:"from"`
- To string `json:"to"`
- Relation string `json:"relation"`
- }
-
- KnowledgeGraphData {
- ID string `json:"id"`
- BrandID string `json:"brand_id"`
- Seed string `json:"seed"`
- Nodes []KnowledgeGraphNodeData `json:"nodes"`
- Edges []KnowledgeGraphEdgeData `json:"edges"`
- BraveSources []BraveSourceData `json:"brave_sources,omitempty"`
- ExpandStrategy string `json:"expand_strategy,omitempty"`
- PainTagCount int `json:"pain_tag_count"`
- GeneratedAt int64 `json:"generated_at"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ExpandKnowledgeGraphReq {
- SeedQuery string `json:"seed_query" validate:"required"`
- Supplemental bool `json:"supplemental,optional"`
- RegenerateMap bool `json:"regenerate_map,optional"`
- ExpandStrategy string `json:"expand_strategy,optional"` // brave | llm | hybrid
- }
-
- ExpandKnowledgeGraphData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
- }
-
- KnowledgeGraphNodeUpdate {
- NodeID string `json:"node_id" validate:"required"`
- SelectedForScan *bool `json:"selected_for_scan,optional"`
- RelevanceTags []string `json:"relevance_tags,optional"`
- RecencyTags []string `json:"recency_tags,optional"`
- }
-
- PatchKnowledgeGraphNodesReq {
- Updates []KnowledgeGraphNodeUpdate `json:"updates" validate:"required"`
- }
-
- StartBrandScanJobReq {
- GraphID string `json:"graph_id,optional"`
- NodeIDs []string `json:"node_ids,optional"`
- DualTrack bool `json:"dual_track,optional"`
- PatrolMode bool `json:"patrol_mode,optional"`
- TestPatrol bool `json:"test_patrol,optional"`
- PatrolKeywords []string `json:"patrol_keywords,optional"`
- }
-
- StartBrandScanJobData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
- }
-
- ListBrandScanPostsReq {
- Priority string `form:"priority,optional"`
- Recent7d bool `form:"recent_7d,optional"`
- ProductFitMin int `form:"product_fit_min,optional"`
- Limit int `form:"limit,optional"`
- }
-
- ScanPostData {
- ID string `json:"id"`
- GraphNodeID string `json:"graph_node_id"`
- SearchTag string `json:"search_tag"`
- QueryDimension string `json:"query_dimension"`
- ExternalID string `json:"external_id"`
- Permalink string `json:"permalink"`
- Author string `json:"author"`
- Text string `json:"text"`
- Priority string `json:"priority"`
- PlacementScore int `json:"placement_score"`
- ProductFitScore int `json:"product_fit_score"`
- SolvedByProduct bool `json:"solved_by_product"`
- Source string `json:"source"`
- ScanJobID string `json:"scan_job_id"`
- OutreachStatus string `json:"outreach_status,omitempty"`
- PublishedReplyID string `json:"published_reply_id,omitempty"`
- PublishedPermalink string `json:"published_permalink,omitempty"`
- OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
- PostedAt string `json:"posted_at,omitempty"`
- RecencyDays int `json:"recency_days,omitempty"`
- Replies []ScanReplyData `json:"replies,omitempty"`
- LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- ListBrandScanPostsData {
- List []ScanPostData `json:"list"`
- Total int `json:"total"`
- }
-
- GenerateOutreachDraftsReq {
- ScanPostID string `json:"scan_post_id" validate:"required"`
- TopicID string `json:"topic_id,optional"`
- Count int `json:"count,optional"`
- VoicePersonaID string `json:"voice_persona_id,optional"`
- ProductID string `json:"product_id,optional"`
- Regenerate bool `json:"regenerate,optional"`
- }
-
- OutreachDraftItemData {
- Text string `json:"text"`
- Angle string `json:"angle"`
- Rationale string `json:"rationale"`
- }
-
- GenerateOutreachDraftsData {
- ID string `json:"id"`
- ScanPostID string `json:"scan_post_id"`
- Relevance float64 `json:"relevance"`
- Reason string `json:"reason"`
- Drafts []OutreachDraftItemData `json:"drafts"`
- CreateAt int64 `json:"create_at"`
- }
-
- PublishOutreachDraftReq {
- ScanPostID string `json:"scan_post_id" validate:"required"`
- Text string `json:"text" validate:"required"`
- Confirm bool `json:"confirm"`
- }
-
- UpdateOutreachDraftReq {
- DraftIndex int `json:"draft_index"`
- Text string `json:"text" validate:"required"`
- }
-
- PublishOutreachDraftData {
- ScanPostID string `json:"scan_post_id"`
- ReplyID string `json:"reply_id"`
- Permalink string `json:"permalink"`
- OutreachStatus string `json:"outreach_status"`
- PublishedPermalink string `json:"published_permalink"`
- Message string `json:"message"`
- }
-
- PatchScanPostOutreachReq {
- OutreachStatus *string `json:"outreach_status,optional"`
- }
-
- ContentMatrixRowData {
- SortOrder int `json:"sort_order"`
- SearchTag string `json:"search_tag"`
- Angle string `json:"angle"`
- Hook string `json:"hook"`
- Text string `json:"text"`
- ReferenceNotes string `json:"reference_notes"`
- SourcePermalinks []string `json:"source_permalinks"`
- Rationale string `json:"rationale"`
- }
-
- ContentMatrixData {
- ID string `json:"id,omitempty"`
- BrandID string `json:"brand_id"`
- Rows []ContentMatrixRowData `json:"rows"`
- GeneratedAt int64 `json:"generated_at"`
- CreateAt int64 `json:"create_at,omitempty"`
- UpdateAt int64 `json:"update_at,omitempty"`
- }
-
- GenerateContentMatrixReq {
- Count int `json:"count,optional"`
- }
-
- ScanReplyData {
- ExternalID string `json:"external_id,omitempty"`
- Author string `json:"author,omitempty"`
- Text string `json:"text"`
- Permalink string `json:"permalink,omitempty"`
- LikeCount int `json:"like_count,omitempty"`
- PostedAt string `json:"posted_at,omitempty"`
- }
-
- BrandScanScheduleData {
- ID string `json:"id,omitempty"`
- BrandID string `json:"brand_id"`
- Cron string `json:"cron"`
- Timezone string `json:"timezone"`
- Enabled bool `json:"enabled"`
- NextRunAt int64 `json:"next_run_at,omitempty"`
- LastRunAt int64 `json:"last_run_at,omitempty"`
- }
-
- UpsertBrandScanScheduleReq {
- Cron string `json:"cron,optional"`
- Timezone string `json:"timezone,optional"`
- Enabled bool `json:"enabled"`
- }
-
- UpdateBrandHandlerReq {
- BrandPath
- UpdateBrandReq
- }
-
- ExpandKnowledgeGraphHandlerReq {
- BrandPath
- ExpandKnowledgeGraphReq
- }
-
- PatchKnowledgeGraphNodesHandlerReq {
- BrandPath
- PatchKnowledgeGraphNodesReq
- }
-
- StartBrandScanJobHandlerReq {
- BrandPath
- StartBrandScanJobReq
- }
-
- ListBrandScanPostsHandlerReq {
- BrandPath
- ListBrandScanPostsReq
- }
-
- GenerateOutreachDraftsHandlerReq {
- BrandPath
- GenerateOutreachDraftsReq
- }
-
- PublishOutreachDraftHandlerReq {
- BrandPath
- PublishOutreachDraftReq
- }
-
- UpdateOutreachDraftHandlerReq {
- BrandPath
- DraftID string `path:"draftId" validate:"required"`
- UpdateOutreachDraftReq
- }
-
- PatchScanPostOutreachHandlerReq {
- BrandPath
- PostID string `path:"postId"`
- PatchScanPostOutreachReq
- }
-
- GenerateContentMatrixHandlerReq {
- BrandPath
- GenerateContentMatrixReq
- }
-
- UpsertBrandScanScheduleHandlerReq {
- BrandPath
- UpsertBrandScanScheduleReq
- }
-)
-
-@server(
- group: brand
- prefix: /api/v1/brands
- middleware: AuthJWT
- tags: "Brand"
- summary: "Brand profiles for placement workflow. Requires Bearer JWT."
-)
-service gateway {
- @handler listBrands
- get / returns (ListBrandsData)
-
- @handler createBrand
- post / (CreateBrandReq) returns (BrandData)
-
- @handler getBrand
- get /:id (BrandPath) returns (BrandData)
-
- @handler updateBrand
- patch /:id (UpdateBrandHandlerReq) returns (BrandData)
-
- @handler deleteBrand
- delete /:id (BrandPath)
-
- @handler listBrandProducts
- get /:id/products (BrandPath) returns (ListBrandProductsData)
-
- @handler createBrandProduct
- post /:id/products (CreateBrandProductHandlerReq) returns (BrandProductData)
-
- @handler updateBrandProduct
- patch /:id/products/:productId (UpdateBrandProductHandlerReq) returns (BrandProductData)
-
- @handler deleteBrandProduct
- delete /:id/products/:productId (BrandProductPath)
-
- @handler expandKnowledgeGraph
- post /:id/knowledge-graph/expand (ExpandKnowledgeGraphHandlerReq) returns (ExpandKnowledgeGraphData)
-
- @handler getKnowledgeGraph
- get /:id/knowledge-graph (BrandPath) returns (KnowledgeGraphData)
-
- @handler patchKnowledgeGraphNodes
- patch /:id/knowledge-graph/nodes (PatchKnowledgeGraphNodesHandlerReq) returns (KnowledgeGraphData)
-
- @handler startBrandScanJob
- post /:id/scan-jobs (StartBrandScanJobHandlerReq) returns (StartBrandScanJobData)
-
- @handler listBrandScanPosts
- get /:id/scan-posts (ListBrandScanPostsHandlerReq) returns (ListBrandScanPostsData)
-
- @handler generateOutreachDrafts
- post /:id/outreach-drafts/generate (GenerateOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
-
- @handler publishOutreachDraft
- post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
-
- @handler updateOutreachDraft
- patch /:id/outreach-drafts/:draftId (UpdateOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
-
- @handler patchScanPostOutreach
- patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)
-
- @handler getBrandContentMatrix
- get /:id/content-matrix (BrandPath) returns (ContentMatrixData)
-
- @handler generateBrandContentMatrix
- post /:id/content-matrix/generate (GenerateContentMatrixHandlerReq) returns (ContentMatrixData)
-
- @handler getBrandScanSchedule
- get /:id/scan-schedule (BrandPath) returns (BrandScanScheduleData)
-
- @handler upsertBrandScanSchedule
- put /:id/scan-schedule (UpsertBrandScanScheduleHandlerReq) returns (BrandScanScheduleData)
-}
diff --git a/old/backend/generate/api/common.api b/old/backend/generate/api/common.api
deleted file mode 100644
index e595197..0000000
--- a/old/backend/generate/api/common.api
+++ /dev/null
@@ -1,15 +0,0 @@
-syntax = "v1"
-
-type ErrorDetail {
- BizCode string `json:"biz_code,optional"`
- Scope int64 `json:"scope,optional"`
- Category int64 `json:"category,optional"`
- Detail int64 `json:"detail,optional"`
-}
-
-type Status {
- Code int64 `json:"code"`
- Message string `json:"message"`
- Data interface{} `json:"data,optional"`
- Error ErrorDetail `json:"error,optional"`
-}
diff --git a/old/backend/generate/api/copy_mission.api b/old/backend/generate/api/copy_mission.api
deleted file mode 100644
index 709fbe7..0000000
--- a/old/backend/generate/api/copy_mission.api
+++ /dev/null
@@ -1,60 +0,0 @@
-syntax = "v1"
-
-// Flow A full copy_mission pipeline removed (stubs / orphan workers).
-// Only topic inspiration remains until Post Project replaces MissionsPage.
-// See docs/post-project-plan.md and docs/cleanup-log.md.
-
-type (
- CopyMissionInspirationReq {
- Keyword string `json:"keyword,optional"`
- ContentDirection string `json:"content_direction,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- AvoidTopics []string `json:"avoid_topics,optional"`
- }
-
- CopyMissionInspirationHandlerReq {
- PersonaID string `path:"personaId" validate:"required"`
- CopyMissionInspirationReq
- }
-
- CopyMissionInspirationSourceData {
- Query string `json:"query,omitempty"`
- Title string `json:"title,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- URL string `json:"url,omitempty"`
- }
-
- CopyMissionInspirationData {
- TopicCandidateID string `json:"topic_candidate_id,omitempty"`
- ContentPlanID string `json:"content_plan_id,omitempty"`
- Label string `json:"label"`
- SeedQuery string `json:"seed_query"`
- Brief string `json:"brief"`
- TrendReason string `json:"trend_reason,omitempty"`
- TrendKeywords []string `json:"trend_keywords,omitempty"`
- Angles []string `json:"angles,omitempty"`
- Mission string `json:"mission,omitempty"`
- TargetAudience string `json:"target_audience,omitempty"`
- OpeningType string `json:"opening_type,omitempty"`
- BodyType string `json:"body_type,omitempty"`
- Emotion string `json:"emotion,omitempty"`
- CtaType string `json:"cta_type,omitempty"`
- RiskLevel string `json:"risk_level,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
- WebSearchUsed bool `json:"web_search_used"`
- Message string `json:"message"`
- }
-)
-
-@server(
- group: copy_mission
- prefix: /api/v1/personas
- middleware: AuthJWT
- tags: "CopyMission"
- summary: "Topic inspiration only (legacy Flow A pipeline removed). Requires Bearer JWT."
-)
-service gateway {
- @handler inspireCopyMission
- post /:personaId/copy-mission-inspiration (CopyMissionInspirationHandlerReq) returns (CopyMissionInspirationData)
-}
diff --git a/old/backend/generate/api/gateway.api b/old/backend/generate/api/gateway.api
deleted file mode 100644
index 6a50145..0000000
--- a/old/backend/generate/api/gateway.api
+++ /dev/null
@@ -1,30 +0,0 @@
-syntax = "v1"
-
-info (
- title: "Haixun Backend"
- desc: "Haixun service-oriented backend core"
- author: "haixun"
- version: "0.1.0"
- host: "127.0.0.1:8890"
- schemes: "http,https"
- consumes: "application/json"
- produces: "application/json"
-)
-
-import (
- "common.api"
- "normal.api"
- "setting.api"
- "ai.api"
- "job.api"
- "auth.api"
- "member.api"
- "permission.api"
- "threads_account.api"
- "persona.api"
- "copy_mission.api"
- "brand.api"
- "placement_topic.api"
- "worker_internal.api"
-)
-
diff --git a/old/backend/generate/api/job.api b/old/backend/generate/api/job.api
deleted file mode 100644
index 7f30807..0000000
--- a/old/backend/generate/api/job.api
+++ /dev/null
@@ -1,248 +0,0 @@
-syntax = "v1"
-
-type (
- JobTemplatePath {
- Type string `path:"type" validate:"required"` // template type
- }
-
- JobIDPath {
- ID string `path:"id" validate:"required"` // job run id
- }
-
- JobScheduleIDPath {
- ID string `path:"id" validate:"required"` // schedule id
- }
-
- ListJobsReq {
- Scope string `form:"scope,optional"` // filter by scope
- ScopeID string `form:"scope_id,optional"` // filter by scope id
- Page int64 `form:"page,optional"` // page number starting at 1
- PageSize int64 `form:"pageSize,optional"` // page size
- }
-
- ListJobSchedulesReq {
- Scope string `form:"scope,optional"` // filter by scope
- ScopeID string `form:"scope_id,optional"` // filter by scope id
- Page int64 `form:"page,optional"` // page number starting at 1
- PageSize int64 `form:"pageSize,optional"` // page size
- }
-
- ListJobEventsReq {
- ID string `path:"id" validate:"required"` // job run id
- Limit int64 `form:"limit,optional"` // max events
- }
-
- CancelJobReq {
- ID string `path:"id" validate:"required"` // job run id
- Reason string `json:"reason,optional"` // cancel reason
- }
-
- CreateJobReq {
- TemplateType string `json:"template_type" validate:"required"` // job template type
- Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope
- ScopeID string `json:"scope_id" validate:"required"` // scope id
- Payload map[string]interface{} `json:"payload,optional"` // job payload
- }
-
- UpsertJobTemplateReq {
- Type string `path:"type" validate:"required"` // template type
- Version int `json:"version,optional"` // template version
- Name string `json:"name" validate:"required"` // display name
- Description string `json:"description,optional"` // description
- Enabled bool `json:"enabled"` // enabled flag
- Repeatable bool `json:"repeatable"` // repeatable flag
- ConcurrencyPolicy string `json:"concurrency_policy,optional"` // concurrency policy
- DedupeKeys []string `json:"dedupe_keys,optional"` // dedupe keys
- TimeoutSeconds int `json:"timeout_seconds,optional"` // timeout seconds
- CancelPolicy JobCancelPolicyData `json:"cancel_policy,optional"` // cancel policy
- RetryPolicy JobRetryPolicyData `json:"retry_policy,optional"` // retry policy
- Steps []JobTemplateStepData `json:"steps" validate:"required,min=1,dive"` // steps
- }
-
- CreateJobScheduleReq {
- TemplateType string `json:"template_type" validate:"required"` // template type
- Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // scope
- ScopeID string `json:"scope_id" validate:"required"` // scope id
- Cron string `json:"cron" validate:"required"` // cron expression
- Timezone string `json:"timezone,optional"` // timezone
- PayloadTemplate map[string]interface{} `json:"payload_template,optional"` // payload template
- Enabled bool `json:"enabled"` // enabled flag
- }
-
- UpdateJobScheduleReq {
- ID string `path:"id" validate:"required"` // schedule id
- Cron string `json:"cron,optional"` // cron expression
- Timezone string `json:"timezone,optional"` // timezone
- PayloadTemplate map[string]interface{} `json:"payload_template,optional"` // payload template
- Enabled *bool `json:"enabled,optional"` // enabled flag
- }
-
- JobCancelPolicyData {
- Supported bool `json:"supported"`
- Mode string `json:"mode"`
- GraceSeconds int `json:"grace_seconds"`
- }
-
- JobRetryPolicyData {
- MaxAttempts int `json:"max_attempts"`
- BackoffSeconds []int `json:"backoff_seconds"`
- }
-
- JobTemplateStepData {
- ID string `json:"id"`
- Name string `json:"name"`
- WorkerType string `json:"worker_type"`
- TimeoutSeconds int `json:"timeout_seconds"`
- Cancelable bool `json:"cancelable"`
- }
-
- JobTemplateData {
- Type string `json:"type"`
- Version int `json:"version"`
- Name string `json:"name"`
- Description string `json:"description"`
- Enabled bool `json:"enabled"`
- Repeatable bool `json:"repeatable"`
- ConcurrencyPolicy string `json:"concurrency_policy"`
- DedupeKeys []string `json:"dedupe_keys"`
- TimeoutSeconds int `json:"timeout_seconds"`
- CancelPolicy JobCancelPolicyData `json:"cancel_policy"`
- RetryPolicy JobRetryPolicyData `json:"retry_policy"`
- Steps []JobTemplateStepData `json:"steps"`
- }
-
- JobTemplateListData {
- List []JobTemplateData `json:"list"`
- }
-
- JobStepProgressData {
- ID string `json:"id"`
- Status string `json:"status"`
- StartedAt *int64 `json:"started_at,optional"`
- EndedAt *int64 `json:"ended_at,optional"`
- Message string `json:"message,optional"`
- }
-
- JobProgressData {
- Summary string `json:"summary"`
- Percentage int `json:"percentage"`
- Steps []JobStepProgressData `json:"steps"`
- }
-
- JobData {
- ID string `json:"id"`
- TemplateType string `json:"template_type"`
- TemplateVersion int `json:"template_version"`
- Scope string `json:"scope"`
- ScopeID string `json:"scope_id"`
- Status string `json:"status"`
- Phase string `json:"phase"`
- WorkerType string `json:"worker_type"`
- Payload map[string]interface{} `json:"payload"`
- Progress JobProgressData `json:"progress"`
- Result map[string]interface{} `json:"result,optional"`
- Error string `json:"error,optional"`
- Attempt int `json:"attempt"`
- MaxAttempts int `json:"max_attempts"`
- CancelRequestedAt *int64 `json:"cancel_requested_at,optional"`
- CancelReason string `json:"cancel_reason,optional"`
- StartedAt *int64 `json:"started_at,optional"`
- CompletedAt *int64 `json:"completed_at,optional"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- JobListData {
- Pagination PaginationData `json:"pagination"`
- List []JobData `json:"list"`
- }
-
- JobScheduleData {
- ID string `json:"id"`
- TemplateType string `json:"template_type"`
- Scope string `json:"scope"`
- ScopeID string `json:"scope_id"`
- Enabled bool `json:"enabled"`
- Cron string `json:"cron"`
- Timezone string `json:"timezone"`
- PayloadTemplate map[string]interface{} `json:"payload_template"`
- LastRunAt *int64 `json:"last_run_at,optional"`
- NextRunAt int64 `json:"next_run_at"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- JobScheduleListData {
- Pagination PaginationData `json:"pagination"`
- List []JobScheduleData `json:"list"`
- }
-
- JobEventData {
- ID string `json:"id"`
- JobID string `json:"job_id"`
- Type string `json:"type"`
- From string `json:"from,optional"`
- To string `json:"to,optional"`
- Message string `json:"message"`
- Metadata map[string]interface{} `json:"metadata,optional"`
- CreateAt int64 `json:"create_at"`
- }
-
- JobEventListData {
- List []JobEventData `json:"list"`
- }
-)
-
-@server(
- group: job
- prefix: /api/v1
- middleware: AuthJWT
- tags: "Job - Core"
- summary: "Generic job templates, runs, schedules, cancel, and retry. Requires Bearer JWT."
-)
-service gateway {
- @handler listJobTemplates
- get /job/templates returns (JobTemplateListData)
-
- @handler getJobTemplate
- get /job/templates/:type (JobTemplatePath) returns (JobTemplateData)
-
- @handler upsertJobTemplate
- put /job/templates/:type (UpsertJobTemplateReq) returns (JobTemplateData)
-
- @handler createJob
- post /jobs (CreateJobReq) returns (JobData)
-
- @handler getJob
- get /jobs/:id (JobIDPath) returns (JobData)
-
- @handler listJobs
- get /jobs (ListJobsReq) returns (JobListData)
-
- @handler listJobEvents
- get /jobs/:id/events (ListJobEventsReq) returns (JobEventListData)
-
- @handler cancelJob
- post /jobs/:id/cancel (CancelJobReq) returns (JobData)
-
- @handler retryJob
- post /jobs/:id/retry (JobIDPath) returns (JobData)
-
- @handler listJobSchedules
- get /job/schedules (ListJobSchedulesReq) returns (JobScheduleListData)
-
- @handler createJobSchedule
- post /job/schedules (CreateJobScheduleReq) returns (JobScheduleData)
-
- @handler updateJobSchedule
- put /job/schedules/:id (UpdateJobScheduleReq) returns (JobScheduleData)
-
- @handler enableJobSchedule
- post /job/schedules/:id/enable (JobScheduleIDPath) returns (JobScheduleData)
-
- @handler disableJobSchedule
- post /job/schedules/:id/disable (JobScheduleIDPath) returns (JobScheduleData)
-
- @handler deleteJobSchedule
- delete /job/schedules/:id (JobScheduleIDPath)
-}
\ No newline at end of file
diff --git a/old/backend/generate/api/member.api b/old/backend/generate/api/member.api
deleted file mode 100644
index 6deb184..0000000
--- a/old/backend/generate/api/member.api
+++ /dev/null
@@ -1,187 +0,0 @@
-syntax = "v1"
-
-type (
- MemberMeData {
- TenantID string `json:"tenant_id"`
- UID string `json:"uid"`
- Email string `json:"email"`
- EmailVerified bool `json:"email_verified"`
- DisplayName string `json:"display_name,omitempty"`
- Avatar string `json:"avatar,omitempty"`
- Phone string `json:"phone,omitempty"`
- Language string `json:"language,omitempty"`
- Currency string `json:"currency,omitempty"`
- Status string `json:"status"`
- Origin string `json:"origin"`
- Roles []string `json:"roles,omitempty"`
- BusinessEmail string `json:"business_email,omitempty"`
- BusinessEmailVerified bool `json:"business_email_verified"`
- BusinessPhone string `json:"business_phone,omitempty"`
- BusinessPhoneVerified bool `json:"business_phone_verified"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- UpdateMemberMeReq {
- DisplayName string `json:"display_name,optional"`
- Avatar string `json:"avatar,optional"`
- Language string `json:"language,optional"`
- Currency string `json:"currency,optional"`
- Phone string `json:"phone,optional"`
- }
-
- ListMembersReq {
- Page int64 `form:"page,optional"`
- PageSize int64 `form:"pageSize,optional"`
- }
-
- ListMembersData {
- Pagination PaginationData `json:"pagination"`
- List []MemberMeData `json:"list"`
- }
-
- MemberPath {
- UID string `path:"uid" validate:"required"`
- }
-
- UpdateMemberRolesReq {
- UID string `path:"uid" validate:"required"`
- Roles []string `json:"roles" validate:"required"`
- }
-
- UpdateMemberPasswordReq {
- UID string `path:"uid" validate:"required"`
- Password string `json:"password" validate:"required,min=8,max=128"`
- }
-
- UpdateMemberProfileReq {
- UID string `path:"uid" validate:"required"`
- DisplayName *string `json:"display_name,optional"`
- Avatar *string `json:"avatar,optional"`
- Language *string `json:"language,optional"`
- Currency *string `json:"currency,optional"`
- Phone *string `json:"phone,optional"`
- Status *string `json:"status,optional"`
- BusinessEmail *string `json:"business_email,optional"`
- BusinessEmailVerified *bool `json:"business_email_verified,optional"`
- BusinessPhone *string `json:"business_phone,optional"`
- BusinessPhoneVerified *bool `json:"business_phone_verified,optional"`
- EmailVerified *bool `json:"email_verified,optional"`
- }
-
- UploadAvatarData {
- Avatar string `json:"avatar"`
- }
-
- MemberPlacementSettingsData {
- WebSearchProvider string `json:"web_search_provider"` // brave | exa
- BraveAPIKey string `json:"brave_api_key,omitempty"`
- BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
- ExaAPIKey string `json:"exa_api_key,omitempty"`
- ExaAPIKeyConfigured bool `json:"exa_api_key_configured"`
- BraveCountry string `json:"brave_country"`
- BraveSearchLang string `json:"brave_search_lang"`
- ExaUserLocation string `json:"exa_user_location"`
- ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
- DevModeEnabled bool `json:"dev_mode_enabled"`
- }
-
- MemberAiSettingsData {
- Provider string `json:"provider"`
- Model string `json:"model"`
- ResearchProvider string `json:"research_provider,omitempty"`
- ResearchModel string `json:"research_model,omitempty"`
- ApiKeys map[string]string `json:"api_keys,omitempty"`
- ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
- }
-
- UpdateMemberAiSettingsReq {
- Provider *string `json:"provider,optional"`
- Model *string `json:"model,optional"`
- ResearchProvider *string `json:"research_provider,optional"`
- ResearchModel *string `json:"research_model,optional"`
- ApiKeys map[string]string `json:"api_keys,optional"`
- }
-
- MemberAiProviderModelsReq {
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
- ApiKey string `json:"api_key,optional"`
- }
-
- MemberAiProviderModelsData {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,omitempty"`
- }
-
- UpdateMemberPlacementSettingsReq {
- WebSearchProvider *string `json:"web_search_provider,optional"`
- BraveAPIKey *string `json:"brave_api_key,optional"`
- ExaAPIKey *string `json:"exa_api_key,optional"`
- BraveCountry *string `json:"brave_country,optional"`
- BraveSearchLang *string `json:"brave_search_lang,optional"`
- ExaUserLocation *string `json:"exa_user_location,optional"`
- ExpandStrategy *string `json:"expand_strategy,optional"`
- DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
- }
-
- MemberCapabilitiesData {
- DiscoverReady bool `json:"discover_ready"`
- AiReady bool `json:"ai_ready"`
- PublishReady bool `json:"publish_ready"`
- DiscoverHint string `json:"discover_hint,omitempty"`
- AiHint string `json:"ai_hint,omitempty"`
- PublishHint string `json:"publish_hint,omitempty"`
- ActiveThreadsAccountId string `json:"active_threads_account_id,omitempty"`
- }
-)
-
-@server(
- group: member
- prefix: /api/v1/members
- middleware: AuthJWT
- tags: "Member"
- summary: "Current member profile endpoints. Requires Bearer JWT or dev headers."
-)
-service gateway {
- @handler listMembers
- get / (ListMembersReq) returns (ListMembersData)
-
- @handler getMemberMe
- get /me returns (MemberMeData)
-
- @handler updateMemberMe
- patch /me (UpdateMemberMeReq) returns (MemberMeData)
-
- @handler uploadMemberAvatar
- post /me/avatar returns (UploadAvatarData)
-
- @handler getMemberPlacementSettings
- get /me/placement-settings returns (MemberPlacementSettingsData)
-
- @handler updateMemberPlacementSettings
- patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData)
-
- @handler getMemberAiSettings
- get /me/ai-settings returns (MemberAiSettingsData)
-
- @handler updateMemberAiSettings
- put /me/ai-settings (UpdateMemberAiSettingsReq) returns (MemberAiSettingsData)
-
- @handler listMemberAiProviderModels
- post /me/ai-settings/providers/:provider/models (MemberAiProviderModelsReq) returns (MemberAiProviderModelsData)
-
- @handler getMemberCapabilities
- get /me/capabilities returns (MemberCapabilitiesData)
-
- @handler updateMemberRoles
- patch /:uid/roles (UpdateMemberRolesReq) returns (MemberMeData)
-
- @handler updateMemberPassword
- patch /:uid/password (UpdateMemberPasswordReq) returns (MemberMeData)
-
- @handler updateMemberProfile
- patch /:uid/profile (UpdateMemberProfileReq) returns (MemberMeData)
-}
diff --git a/old/backend/generate/api/normal.api b/old/backend/generate/api/normal.api
deleted file mode 100644
index 8310530..0000000
--- a/old/backend/generate/api/normal.api
+++ /dev/null
@@ -1,16 +0,0 @@
-syntax = "v1"
-
-type HealthData {
- Pong string `json:"pong"`
-}
-
-@server(
- group: normal
- prefix: /api/v1
- tags: "Normal - Public"
- summary: "Health check"
-)
-service gateway {
- @handler health
- get /health () returns (HealthData)
-}
diff --git a/old/backend/generate/api/permission.api b/old/backend/generate/api/permission.api
deleted file mode 100644
index 8a9f61f..0000000
--- a/old/backend/generate/api/permission.api
+++ /dev/null
@@ -1,52 +0,0 @@
-syntax = "v1"
-
-type (
- PermissionCatalogQuery {
- Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
- Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
- Tree bool `form:"tree,optional"`
- }
-
- PermissionNode {
- ID string `json:"id"`
- Parent string `json:"parent,omitempty"`
- Name string `json:"name"`
- HTTPMethods string `json:"http_methods,omitempty"`
- HTTPPath string `json:"http_path,omitempty"`
- Status string `json:"status"`
- Type string `json:"type"`
- Children []PermissionNode `json:"children,omitempty"`
- }
-
- PermissionCatalogData {
- Tree []PermissionNode `json:"tree,omitempty"`
- List []PermissionNode `json:"list,omitempty"`
- }
-
- MePermissionsQuery {
- IncludeTree bool `form:"include_tree,optional"`
- }
-
- MePermissionsData {
- UID string `json:"uid"`
- TenantID string `json:"tenant_id"`
- Roles []string `json:"roles"`
- Permissions map[string]string `json:"permissions"`
- Tree []PermissionNode `json:"tree,omitempty"`
- }
-)
-
-@server(
- group: permission
- prefix: /api/v1/permissions
- middleware: AuthJWT
- tags: "Permission"
- summary: "Permission catalog and current member permissions. Requires Bearer JWT."
-)
-service gateway {
- @handler getPermissionCatalog
- get /catalog (PermissionCatalogQuery) returns (PermissionCatalogData)
-
- @handler getMePermissions
- get /me (MePermissionsQuery) returns (MePermissionsData)
-}
diff --git a/old/backend/generate/api/persona.api b/old/backend/generate/api/persona.api
deleted file mode 100644
index e363ff0..0000000
--- a/old/backend/generate/api/persona.api
+++ /dev/null
@@ -1,736 +0,0 @@
-syntax = "v1"
-
-type (
- CopyResearchMapData {
- AudienceSummary string `json:"audience_summary,omitempty"`
- ContentGoal string `json:"content_goal,omitempty"`
- Questions []string `json:"questions,omitempty"`
- Pillars []string `json:"pillars,omitempty"`
- Exclusions []string `json:"exclusions,omitempty"`
- SuggestedTags []string `json:"suggested_tags,omitempty"`
- BenchmarkNotes string `json:"benchmark_notes,omitempty"`
- }
-
- PersonaData {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- Persona string `json:"persona,omitempty"`
- Brief string `json:"brief,omitempty"`
- StyleProfile string `json:"style_profile,omitempty"`
- StyleBenchmark string `json:"style_benchmark,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- CopyResearchMap CopyResearchMapData `json:"copy_research_map,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ListPersonasData {
- List []PersonaData `json:"list"`
- }
-
- CreatePersonaReq {
- DisplayName string `json:"display_name,optional"`
- }
-
- PersonaPath {
- ID string `path:"id" validate:"required"`
- }
-
- UpdatePersonaReq {
- DisplayName *string `json:"display_name,optional"`
- Persona *string `json:"persona,optional"`
- Brief *string `json:"brief,optional"`
- StyleProfile *string `json:"style_profile,optional"`
- StyleBenchmark *string `json:"style_benchmark,optional"`
- }
-
- StartPersonaStyleAnalysisReq {
- BenchmarkUsername string `json:"benchmark_username" validate:"required"`
- }
-
- StartPersonaStyleAnalysisData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
- }
-
- UpdatePersonaHandlerReq {
- PersonaPath
- UpdatePersonaReq
- }
-
- StartPersonaStyleAnalysisHandlerReq {
- PersonaPath
- StartPersonaStyleAnalysisReq
- }
-
- StartPersonaStyleAnalysisFromTextReq {
- ReferenceTexts []string `json:"reference_texts,optional"`
- RawText string `json:"raw_text,optional"`
- SourceLabel string `json:"source_label,optional"`
- }
-
- StartPersonaStyleAnalysisFromTextData {
- Persona PersonaData `json:"persona"`
- PostCount int `json:"post_count"`
- Message string `json:"message"`
- }
-
- StartPersonaStyleAnalysisFromTextHandlerReq {
- PersonaPath
- StartPersonaStyleAnalysisFromTextReq
- }
-
- StartPersonaViralScanJobReq {
- Keywords []string `json:"keywords,optional"`
- }
-
- StartPersonaViralScanJobData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
- }
-
- StartPersonaViralScanJobHandlerReq {
- PersonaPath
- StartPersonaViralScanJobReq
- }
-
- ListPersonaViralScanPostsReq {
- Limit int `form:"limit,optional"`
- }
-
- ViralScanPostData {
- ID string `json:"id"`
- SearchTag string `json:"search_tag"`
- Permalink string `json:"permalink"`
- Author string `json:"author"`
- AuthorVerified bool `json:"author_verified,omitempty"`
- FollowerCount int `json:"follower_count,omitempty"`
- Text string `json:"text"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- EngagementScore int `json:"engagement_score"`
- Source string `json:"source"`
- ScanJobID string `json:"scan_job_id"`
- Replies []ScanReplyData `json:"replies,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- ListPersonaViralScanPostsData {
- List []ViralScanPostData `json:"list"`
- Total int `json:"total"`
- }
-
- ListPersonaViralScanPostsHandlerReq {
- PersonaPath
- ListPersonaViralScanPostsReq
- }
-
- CopyDraftData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- ContentPlanID string `json:"content_plan_id,omitempty"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- ScanPostID string `json:"scan_post_id,omitempty"`
- FormulaID string `json:"formula_id,omitempty"`
- DraftType string `json:"draft_type"`
- SortOrder int `json:"sort_order,omitempty"`
- Text string `json:"text"`
- TopicTag string `json:"topic_tag,omitempty"`
- Angle string `json:"angle,omitempty"`
- Hook string `json:"hook,omitempty"`
- Rationale string `json:"rationale,omitempty"`
- ReferenceNotes string `json:"reference_notes,omitempty"`
- Sources []string `json:"sources,omitempty"`
- AiScore int `json:"ai_score,omitempty"`
- FormulaScore int `json:"formula_score,omitempty"`
- BrandFitScore int `json:"brand_fit_score,omitempty"`
- RiskScore int `json:"risk_score,omitempty"`
- SimilarityScore int `json:"similarity_score,omitempty"`
- EngagementPotential int `json:"engagement_potential,omitempty"`
- FreshnessScore int `json:"freshness_score,omitempty"`
- ReviewSuggestion string `json:"review_suggestion,omitempty"`
- Status string `json:"status,omitempty"`
- PublishQueueID string `json:"publish_queue_id,omitempty"`
- PublishedMediaID string `json:"published_media_id,omitempty"`
- PublishedPermalink string `json:"published_permalink,omitempty"`
- PublishedAt int64 `json:"published_at,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- ListPersonaCopyDraftsData {
- List []CopyDraftData `json:"list"`
- Total int `json:"total"`
- }
-
- GeneratePersonaCopyDraftReq {
- ScanPostID string `json:"scan_post_id" validate:"required"`
- }
-
- GeneratePersonaCopyDraftHandlerReq {
- PersonaPath
- GeneratePersonaCopyDraftReq
- }
-
- GeneratePersonaCopyDraftData {
- Draft CopyDraftData `json:"draft"`
- Message string `json:"message"`
- }
-
- CopyDraftPath {
- ID string `path:"id" validate:"required"`
- DraftID string `path:"draftId" validate:"required"`
- }
-
- UpdateCopyDraftReq {
- Text *string `json:"text,optional"`
- TopicTag *string `json:"topic_tag,optional"`
- Hook *string `json:"hook,optional"`
- Angle *string `json:"angle,optional"`
- Rationale *string `json:"rationale,optional"`
- Status *string `json:"status,optional"`
- }
-
- TopicCandidateData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Name string `json:"name"`
- Source string `json:"source,omitempty"`
- Category string `json:"category,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- TrendReason string `json:"trend_reason,omitempty"`
- TrendKeywords []string `json:"trend_keywords,omitempty"`
- HeatScore int `json:"heat_score,omitempty"`
- FitScore int `json:"fit_score,omitempty"`
- InteractionScore int `json:"interaction_score,omitempty"`
- ExtendScore int `json:"extend_score,omitempty"`
- RiskScore int `json:"risk_score,omitempty"`
- FinalScore float64 `json:"final_score,omitempty"`
- RecommendedMissions []string `json:"recommended_missions,omitempty"`
- Status string `json:"status,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ContentPlanData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- TopicCandidateID string `json:"topic_candidate_id,omitempty"`
- Topic string `json:"topic"`
- Mission string `json:"mission"`
- TargetAudience string `json:"target_audience,omitempty"`
- Angle string `json:"angle,omitempty"`
- OpeningType string `json:"opening_type,omitempty"`
- BodyType string `json:"body_type,omitempty"`
- Emotion string `json:"emotion,omitempty"`
- EndingType string `json:"ending_type,omitempty"`
- CtaType string `json:"cta_type,omitempty"`
- RiskLevel string `json:"risk_level,omitempty"`
- RequiresHumanReview bool `json:"requires_human_review,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- SelectedKnowledge []string `json:"selected_knowledge,omitempty"`
- Status string `json:"status,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- FeedbackEventData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- ContentPlanID string `json:"content_plan_id,omitempty"`
- DraftID string `json:"draft_id,omitempty"`
- Decision string `json:"decision"`
- Note string `json:"note,omitempty"`
- Snapshot string `json:"snapshot,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- KnowledgeSourceData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- SourceType string `json:"source_type"`
- Title string `json:"title,omitempty"`
- Filename string `json:"filename,omitempty"`
- ParsedStatus string `json:"parsed_status,omitempty"`
- ChunkCount int `json:"chunk_count,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- KnowledgeChunkData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- SourceID string `json:"source_id"`
- Content string `json:"content"`
- Topics []string `json:"topics,omitempty"`
- StyleTags []string `json:"style_tags,omitempty"`
- RiskLevel string `json:"risk_level,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- FormulaPoolData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Type string `json:"type"`
- Name string `json:"name"`
- Pattern string `json:"pattern,omitempty"`
- UseCases []string `json:"use_cases,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- Weight int `json:"weight,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- CreateKnowledgeSourceReq {
- SourceType string `json:"source_type" validate:"required"`
- Title string `json:"title,optional"`
- Filename string `json:"filename,optional"`
- Content string `json:"content,optional"`
- ContentBase64 string `json:"content_base64,optional"`
- }
-
- CreateKnowledgeSourceHandlerReq {
- PersonaPath
- CreateKnowledgeSourceReq
- }
-
- ListKnowledgeSourcesData { List []KnowledgeSourceData `json:"list"` }
- ListKnowledgeChunksData { List []KnowledgeChunkData `json:"list"` }
- ListFormulaPoolsData { List []FormulaPoolData `json:"list"` }
-
- CreateFormulaPoolReq {
- Type string `json:"type" validate:"required"`
- Name string `json:"name" validate:"required"`
- Pattern string `json:"pattern,optional"`
- UseCases []string `json:"use_cases,optional"`
- Avoid []string `json:"avoid,optional"`
- Weight int `json:"weight,optional"`
- }
-
- CreateFormulaPoolHandlerReq {
- PersonaPath
- CreateFormulaPoolReq
- }
-
- ListTopicCandidatesData {
- List []TopicCandidateData `json:"list"`
- }
-
- ListContentPlansData {
- List []ContentPlanData `json:"list"`
- }
-
- CreateContentPlanReq {
- TopicCandidateID string `json:"topic_candidate_id,optional"`
- Topic string `json:"topic" validate:"required"`
- Mission string `json:"mission" validate:"required"`
- TargetAudience string `json:"target_audience,optional"`
- Angle string `json:"angle,optional"`
- OpeningType string `json:"opening_type,optional"`
- BodyType string `json:"body_type,optional"`
- Emotion string `json:"emotion,optional"`
- EndingType string `json:"ending_type,optional"`
- CtaType string `json:"cta_type,optional"`
- RiskLevel string `json:"risk_level,optional"`
- RequiresHumanReview bool `json:"requires_human_review,optional"`
- Avoid []string `json:"avoid,optional"`
- SelectedKnowledge []string `json:"selected_knowledge,optional"`
- }
-
- CreateContentPlanHandlerReq {
- PersonaPath
- CreateContentPlanReq
- }
-
- ContentPlanPath {
- ID string `path:"id" validate:"required"`
- ContentPlanID string `path:"contentPlanId" validate:"required"`
- }
-
- UpdateContentPlanReq {
- Topic *string `json:"topic,optional"`
- Mission *string `json:"mission,optional"`
- TargetAudience *string `json:"target_audience,optional"`
- Angle *string `json:"angle,optional"`
- OpeningType *string `json:"opening_type,optional"`
- BodyType *string `json:"body_type,optional"`
- Emotion *string `json:"emotion,optional"`
- EndingType *string `json:"ending_type,optional"`
- CtaType *string `json:"cta_type,optional"`
- RiskLevel *string `json:"risk_level,optional"`
- RequiresHumanReview *bool `json:"requires_human_review,optional"`
- Avoid []string `json:"avoid,optional"`
- SelectedKnowledge []string `json:"selected_knowledge,optional"`
- Status *string `json:"status,optional"`
- }
-
- UpdateContentPlanHandlerReq {
- ContentPlanPath
- UpdateContentPlanReq
- }
-
- CreateFeedbackEventReq {
- ContentPlanID string `json:"content_plan_id,optional"`
- DraftID string `json:"draft_id,optional"`
- Decision string `json:"decision" validate:"required"`
- Note string `json:"note,optional"`
- Snapshot string `json:"snapshot,optional"`
- }
-
- CreateFeedbackEventHandlerReq {
- PersonaPath
- CreateFeedbackEventReq
- }
-
- UpdateCopyDraftHandlerReq {
- CopyDraftPath
- UpdateCopyDraftReq
- }
-
- PublishCopyDraftReq {
- Text string `json:"text,optional"`
- TopicTag string `json:"topic_tag,optional"`
- Confirm bool `json:"confirm"`
- }
-
- PublishCopyDraftHandlerReq {
- CopyDraftPath
- PublishCopyDraftReq
- }
-
- PublishCopyDraftData {
- DraftID string `json:"draft_id"`
- MediaID string `json:"media_id"`
- Permalink string `json:"permalink,omitempty"`
- Status string `json:"status"`
- Message string `json:"message"`
- }
-
- ScheduleCopyDraftsReq {
- AccountID string `json:"account_id" validate:"required"`
- DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
- StartAt int64 `json:"start_at,optional"`
- Timezone string `json:"timezone,optional"`
- Slots []PublishSlotData `json:"slots,optional"`
- Mode string `json:"mode,optional"`
- }
-
- ScheduleCopyDraftsData {
- Scheduled int `json:"scheduled"`
- List []PublishQueueItemData `json:"list"`
- Message string `json:"message"`
- }
-
- SchedulePersonaDraftsHandlerReq {
- PersonaPath
- ScheduleCopyDraftsReq
- }
-
- StylePresetData {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Name string `json:"name"`
- Tone string `json:"tone,omitempty"`
- CTA []string `json:"cta,omitempty"`
- BannedWords []string `json:"banned_words,omitempty"`
- Notes string `json:"notes,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ListStylePresetsData {
- List []StylePresetData `json:"list"`
- }
-
- UpsertStylePresetReq {
- Name string `json:"name" validate:"required"`
- Tone string `json:"tone,optional"`
- CTA []string `json:"cta,optional"`
- BannedWords []string `json:"banned_words,optional"`
- Notes string `json:"notes,optional"`
- Apply bool `json:"apply,optional"`
- }
-
- StylePresetPath {
- ID string `path:"id" validate:"required"`
- PresetID string `path:"presetId" validate:"required"`
- }
-
- UpsertStylePresetHandlerReq {
- StylePresetPath
- UpsertStylePresetReq
- }
-
- DeleteCopyDraftData {
- DraftID string `json:"draft_id"`
- Message string `json:"message"`
- }
-
- PrunePersonaCopyDraftsReq {
- Keep int `json:"keep,optional"`
- }
-
- PrunePersonaCopyDraftsHandlerReq {
- PersonaPath
- PrunePersonaCopyDraftsReq
- }
-
- PrunePersonaCopyDraftsData {
- Deleted int64 `json:"deleted"`
- Kept int `json:"kept"`
- Message string `json:"message"`
- }
-
- ListPersonaContentInboxReq {
- Page int64 `form:"page,optional"`
- PageSize int64 `form:"pageSize,optional"`
- Status string `form:"status,optional"`
- RangeStart int64 `form:"rangeStart,optional"`
- RangeEnd int64 `form:"rangeEnd,optional"`
- }
-
- ListPersonaContentInboxHandlerReq {
- PersonaPath
- ListPersonaContentInboxReq
- }
-
- ContentInboxItemData {
- CopyDraftData
- ScheduledAt int64 `json:"scheduled_at,omitempty"`
- Lifecycle string `json:"lifecycle"`
- GroupDate int64 `json:"group_date"`
- FormulaLabel string `json:"formula_label,omitempty"`
- }
-
- ListPersonaContentInboxData {
- Pagination PaginationData `json:"pagination"`
- List []ContentInboxItemData `json:"list"`
- }
-
- SchedulePersonaCopyDraftReq {
- AccountID string `json:"account_id" validate:"required"`
- TopicTag string `json:"topic_tag,optional"`
- ScheduledAt int64 `json:"scheduled_at,optional"`
- }
-
- SchedulePersonaCopyDraftHandlerReq {
- CopyDraftPath
- SchedulePersonaCopyDraftReq
- }
-
- ContentFormulaPath {
- ID string `path:"id" validate:"required"`
- FormulaID string `path:"formulaId" validate:"required"`
- }
-
- GenerateFromContentFormulaReq {
- AccountID string `json:"account_id" validate:"required"`
- Topic string `json:"topic" validate:"required"`
- Brief string `json:"brief,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
- }
-
- GenerateFromContentFormulaHandlerReq {
- ContentFormulaPath
- GenerateFromContentFormulaReq
- }
-
- GenerateFromContentFormulaData {
- List []CopyDraftData `json:"list"`
- Message string `json:"message"`
- }
-
- GeneratePersonaTopicMatrixReq {
- Topic string `json:"topic" validate:"required"`
- ContentPlanID string `json:"content_plan_id,optional"`
- Brief string `json:"brief,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
- }
-
- GeneratePersonaTopicMatrixHandlerReq {
- PersonaPath
- GeneratePersonaTopicMatrixReq
- }
-
- GeneratePersonaTopicMatrixData {
- List []CopyDraftData `json:"list"`
- Message string `json:"message"`
- }
-
- StartPersonaTopicMatrixJobHandlerReq {
- PersonaPath
- GeneratePersonaTopicMatrixReq
- }
-
- StartPersonaTopicMatrixJobData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- AiProvider string `json:"ai_provider"`
- AiModel string `json:"ai_model"`
- Message string `json:"message"`
- }
-
- StartPersonaFormulaDraftJobReq {
- AccountID string `json:"account_id" validate:"required"`
- FormulaID string `json:"formula_id" validate:"required"`
- Topic string `json:"topic" validate:"required"`
- Brief string `json:"brief,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
- }
-
- StartPersonaFormulaDraftJobHandlerReq {
- PersonaPath
- StartPersonaFormulaDraftJobReq
- }
-
- StartPersonaFormulaDraftJobData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- AiProvider string `json:"ai_provider"`
- AiModel string `json:"ai_model"`
- Message string `json:"message"`
- }
-
- StartPersonaRewriteDraftJobReq {
- AccountID string `json:"account_id" validate:"required"`
- ReferenceText string `json:"reference_text" validate:"required"`
- Topic string `json:"topic" validate:"required"`
- Brief string `json:"brief,optional"`
- SaveLabel string `json:"save_label,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
- }
-
- StartPersonaRewriteDraftJobHandlerReq {
- PersonaPath
- StartPersonaRewriteDraftJobReq
- }
-
- StartPersonaRewriteDraftJobData {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- AiProvider string `json:"ai_provider"`
- AiModel string `json:"ai_model"`
- Message string `json:"message"`
- }
-)
-
-@server(
- group: persona
- prefix: /api/v1/personas
- middleware: AuthJWT
- tags: "Persona"
- summary: "Reusable persona profiles with 8D style strategy. Requires Bearer JWT."
-)
-service gateway {
- @handler listPersonas
- get / returns (ListPersonasData)
-
- @handler createPersona
- post / (CreatePersonaReq) returns (PersonaData)
-
- @handler getPersona
- get /:id (PersonaPath) returns (PersonaData)
-
- @handler updatePersona
- patch /:id (UpdatePersonaHandlerReq) returns (PersonaData)
-
- @handler deletePersona
- delete /:id (PersonaPath)
-
- @handler startPersonaStyleAnalysis
- post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
-
- @handler startPersonaStyleAnalysisFromText
- post /:id/style-analysis-from-text (StartPersonaStyleAnalysisFromTextHandlerReq) returns (StartPersonaStyleAnalysisFromTextData)
-
- @handler startPersonaViralScanJob
- post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
-
- @handler listPersonaViralScanPosts
- get /:id/viral-scan-posts (ListPersonaViralScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
-
- @handler listPersonaCopyDrafts
- get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
-
- @handler listPersonaContentInbox
- get /:id/content-inbox (ListPersonaContentInboxHandlerReq) returns (ListPersonaContentInboxData)
-
- @handler listTopicCandidates
- get /:id/topic-candidates (PersonaPath) returns (ListTopicCandidatesData)
-
- @handler listContentPlans
- get /:id/content-plans (PersonaPath) returns (ListContentPlansData)
-
- @handler createContentPlan
- post /:id/content-plans (CreateContentPlanHandlerReq) returns (ContentPlanData)
-
- @handler updateContentPlan
- patch /:id/content-plans/:contentPlanId (UpdateContentPlanHandlerReq) returns (ContentPlanData)
-
- @handler createFeedbackEvent
- post /:id/feedback-events (CreateFeedbackEventHandlerReq) returns (FeedbackEventData)
-
- @handler createKnowledgeSource
- post /:id/knowledge-sources (CreateKnowledgeSourceHandlerReq) returns (KnowledgeSourceData)
-
- @handler listKnowledgeSources
- get /:id/knowledge-sources (PersonaPath) returns (ListKnowledgeSourcesData)
-
- @handler listKnowledgeChunks
- get /:id/knowledge-chunks (PersonaPath) returns (ListKnowledgeChunksData)
-
- @handler listFormulaPools
- get /:id/formula-pools (PersonaPath) returns (ListFormulaPoolsData)
-
- @handler createFormulaPool
- post /:id/formula-pools (CreateFormulaPoolHandlerReq) returns (FormulaPoolData)
-
- @handler generatePersonaCopyDraft
- post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
-
- @handler updatePersonaCopyDraft
- patch /:id/copy-drafts/:draftId (UpdateCopyDraftHandlerReq) returns (CopyDraftData)
-
- @handler publishPersonaCopyDraft
- post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
-
- @handler schedulePersonaDrafts
- post /:id/copy-drafts/schedule (SchedulePersonaDraftsHandlerReq) returns (ScheduleCopyDraftsData)
-
- @handler schedulePersonaCopyDraft
- post /:id/copy-drafts/:draftId/schedule (SchedulePersonaCopyDraftHandlerReq) returns (ScheduleCopyDraftsData)
-
- @handler generateFromContentFormula
- post /:id/content-formulas/:formulaId/generate (GenerateFromContentFormulaHandlerReq) returns (GenerateFromContentFormulaData)
-
- @handler generatePersonaTopicMatrix
- post /:id/topic-matrix/generate (GeneratePersonaTopicMatrixHandlerReq) returns (GeneratePersonaTopicMatrixData)
-
- @handler startPersonaTopicMatrixJob
- post /:id/topic-matrix/jobs (StartPersonaTopicMatrixJobHandlerReq) returns (StartPersonaTopicMatrixJobData)
-
- @handler startPersonaFormulaDraftJob
- post /:id/formula-draft/jobs (StartPersonaFormulaDraftJobHandlerReq) returns (StartPersonaFormulaDraftJobData)
-
- @handler startPersonaRewriteDraftJob
- post /:id/rewrite-draft/jobs (StartPersonaRewriteDraftJobHandlerReq) returns (StartPersonaRewriteDraftJobData)
-
- @handler deletePersonaCopyDraft
- delete /:id/copy-drafts/:draftId (CopyDraftPath) returns (DeleteCopyDraftData)
-
- @handler prunePersonaCopyDrafts
- post /:id/copy-drafts/prune (PrunePersonaCopyDraftsHandlerReq) returns (PrunePersonaCopyDraftsData)
-
- @handler listStylePresets
- get /:id/style-presets (PersonaPath) returns (ListStylePresetsData)
-
- @handler upsertStylePreset
- put /:id/style-presets/:presetId (UpsertStylePresetHandlerReq) returns (StylePresetData)
-
- @handler deleteStylePreset
- delete /:id/style-presets/:presetId (StylePresetPath)
-}
diff --git a/old/backend/generate/api/placement_topic.api b/old/backend/generate/api/placement_topic.api
deleted file mode 100644
index a0b12d9..0000000
--- a/old/backend/generate/api/placement_topic.api
+++ /dev/null
@@ -1,216 +0,0 @@
-syntax = "v1"
-
-type (
- PlacementTopicData {
- ID string `json:"id"`
- BrandID string `json:"brand_id"`
- BrandDisplayName string `json:"brand_display_name,omitempty"`
- TopicName string `json:"topic_name,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- Brief string `json:"brief,omitempty"`
- ProductID string `json:"product_id,omitempty"`
- ResearchMap ResearchMapData `json:"research_map,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ListPlacementTopicsData {
- List []PlacementTopicData `json:"list"`
- }
-
- CreatePlacementTopicReq {
- BrandID string `json:"brand_id" validate:"required"`
- TopicName string `json:"topic_name" validate:"required"`
- SeedQuery string `json:"seed_query" validate:"required"`
- Brief string `json:"brief" validate:"required"`
- ProductID string `json:"product_id,optional"`
- }
-
- UpdatePlacementTopicReq {
- BrandID *string `json:"brand_id,optional"`
- TopicName *string `json:"topic_name,optional"`
- SeedQuery *string `json:"seed_query,optional"`
- Brief *string `json:"brief,optional"`
- ProductID *string `json:"product_id,optional"`
- AudienceSummary *string `json:"audience_summary,optional"`
- ContentGoal *string `json:"content_goal,optional"`
- Questions []string `json:"questions,optional"`
- Pillars []string `json:"pillars,optional"`
- Exclusions []string `json:"exclusions,optional"`
- PatrolKeywords []string `json:"patrol_keywords,optional"`
- }
-
- PlacementTopicPath {
- ID string `path:"id" validate:"required"`
- }
-
- UpdatePlacementTopicHandlerReq {
- PlacementTopicPath
- UpdatePlacementTopicReq
- }
-
- CreatePlacementTopicHandlerReq {
- CreatePlacementTopicReq
- }
-
- ExpandPlacementTopicGraphHandlerReq {
- PlacementTopicPath
- ExpandKnowledgeGraphReq
- }
-
- PatchPlacementTopicGraphNodesHandlerReq {
- PlacementTopicPath
- PatchKnowledgeGraphNodesReq
- }
-
- StartPlacementTopicScanJobHandlerReq {
- PlacementTopicPath
- StartBrandScanJobReq
- }
-
- ListPlacementTopicScanPostsHandlerReq {
- PlacementTopicPath
- ListBrandScanPostsReq
- }
-
- GeneratePlacementTopicOutreachDraftsHandlerReq {
- PlacementTopicPath
- GenerateOutreachDraftsReq
- }
-
- PublishPlacementTopicOutreachDraftHandlerReq {
- PlacementTopicPath
- PublishOutreachDraftReq
- }
-
- UpdatePlacementTopicOutreachDraftHandlerReq {
- PlacementTopicPath
- DraftID string `path:"draftId" validate:"required"`
- UpdateOutreachDraftReq
- }
-
- PatchPlacementTopicScanPostOutreachHandlerReq {
- PlacementTopicPath
- PostID string `path:"postId"`
- PatchScanPostOutreachReq
- }
-
- DeletePlacementTopicScanPostHandlerReq {
- PlacementTopicPath
- PostID string `path:"postId" validate:"required"`
- }
-
- BatchDeletePlacementTopicScanPostsReq {
- PostIDs []string `json:"post_ids" validate:"required"`
- }
-
- BatchDeletePlacementTopicScanPostsData {
- DeletedCount int `json:"deleted_count"`
- }
-
- BatchDeletePlacementTopicScanPostsHandlerReq {
- PlacementTopicPath
- BatchDeletePlacementTopicScanPostsReq
- }
-
- GeneratePlacementTopicContentMatrixHandlerReq {
- PlacementTopicPath
- GenerateContentMatrixReq
- }
-
- UpsertPlacementTopicScanScheduleHandlerReq {
- PlacementTopicPath
- UpsertBrandScanScheduleReq
- }
-
- StartPlacementTopicOutreachDraftJobReq {
- ScanPostID string `json:"scan_post_id,optional"`
- ScanPostIDs []string `json:"scan_post_ids,optional"`
- Count int `json:"count,optional"`
- VoicePersonaID string `json:"voice_persona_id,optional"`
- ProductID string `json:"product_id,optional"`
- Regenerate bool `json:"regenerate,optional"`
- }
-
- StartPlacementTopicOutreachDraftJobHandlerReq {
- PlacementTopicPath
- StartPlacementTopicOutreachDraftJobReq
- }
-
- StartPlacementTopicOutreachDraftJobsData {
- Jobs []StartBrandScanJobData `json:"jobs"`
- Message string `json:"message"`
- }
-)
-
-@server(
- group: placement_topic
- prefix: /api/v1/placement/topics
- middleware: AuthJWT
- tags: "Placement Topic"
- summary: "找 TA 主題;每個主題關聯一個品牌,一個品牌可有多個主題。Requires Bearer JWT."
-)
-service gateway {
- @handler listPlacementTopics
- get / returns (ListPlacementTopicsData)
-
- @handler createPlacementTopic
- post / (CreatePlacementTopicHandlerReq) returns (PlacementTopicData)
-
- @handler getPlacementTopic
- get /:id (PlacementTopicPath) returns (PlacementTopicData)
-
- @handler updatePlacementTopic
- patch /:id (UpdatePlacementTopicHandlerReq) returns (PlacementTopicData)
-
- @handler deletePlacementTopic
- delete /:id (PlacementTopicPath)
-
- @handler expandPlacementTopicGraph
- post /:id/knowledge-graph/expand (ExpandPlacementTopicGraphHandlerReq) returns (ExpandKnowledgeGraphData)
-
- @handler getPlacementTopicGraph
- get /:id/knowledge-graph (PlacementTopicPath) returns (KnowledgeGraphData)
-
- @handler patchPlacementTopicGraphNodes
- patch /:id/knowledge-graph/nodes (PatchPlacementTopicGraphNodesHandlerReq) returns (KnowledgeGraphData)
-
- @handler startPlacementTopicScanJob
- post /:id/scan-jobs (StartPlacementTopicScanJobHandlerReq) returns (StartBrandScanJobData)
-
- @handler listPlacementTopicScanPosts
- get /:id/scan-posts (ListPlacementTopicScanPostsHandlerReq) returns (ListBrandScanPostsData)
-
- @handler generatePlacementTopicOutreachDrafts
- post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
-
- @handler startPlacementTopicOutreachDraftJob
- post /:id/outreach-draft-jobs (StartPlacementTopicOutreachDraftJobHandlerReq) returns (StartPlacementTopicOutreachDraftJobsData)
-
- @handler publishPlacementTopicOutreachDraft
- post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
-
- @handler updatePlacementTopicOutreachDraft
- patch /:id/outreach-drafts/:draftId (UpdatePlacementTopicOutreachDraftHandlerReq) returns (GenerateOutreachDraftsData)
-
- @handler patchPlacementTopicScanPostOutreach
- patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)
-
- @handler deletePlacementTopicScanPost
- delete /:id/scan-posts/:postId (DeletePlacementTopicScanPostHandlerReq)
-
- @handler batchDeletePlacementTopicScanPosts
- post /:id/scan-posts/batch-delete (BatchDeletePlacementTopicScanPostsHandlerReq) returns (BatchDeletePlacementTopicScanPostsData)
-
- @handler getPlacementTopicContentMatrix
- get /:id/content-matrix (PlacementTopicPath) returns (ContentMatrixData)
-
- @handler generatePlacementTopicContentMatrix
- post /:id/content-matrix/generate (GeneratePlacementTopicContentMatrixHandlerReq) returns (ContentMatrixData)
-
- @handler getPlacementTopicScanSchedule
- get /:id/scan-schedule (PlacementTopicPath) returns (BrandScanScheduleData)
-
- @handler upsertPlacementTopicScanSchedule
- put /:id/scan-schedule (UpsertPlacementTopicScanScheduleHandlerReq) returns (BrandScanScheduleData)
-}
diff --git a/old/backend/generate/api/setting.api b/old/backend/generate/api/setting.api
deleted file mode 100644
index 1dc68a2..0000000
--- a/old/backend/generate/api/setting.api
+++ /dev/null
@@ -1,68 +0,0 @@
-syntax = "v1"
-
-type (
- SettingPath {
- Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
- ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID,例如 user_id、account_id 或 global
- Page int64 `form:"page,optional"` // 頁碼,從 1 開始
- PageSize int64 `form:"pageSize,optional"` // 每頁筆數,server 會限制最大值
- }
-
- SettingKeyPath {
- Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
- ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID,例如 user_id、account_id 或 global
- Key string `path:"key" validate:"required"` // 設定 key,例如 ai.default
- }
-
- SettingUpsertReq {
- Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
- ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID,例如 user_id、account_id 或 global
- Key string `path:"key" validate:"required"` // 設定 key,例如 ai.default
- Value map[string]interface{} `json:"value" validate:"required"` // 設定內容 JSON object
- Version int `json:"version,optional"` // schema version,未帶入時預設 1
- }
-
- SettingData {
- ID string `json:"id"`
- Scope string `json:"scope"`
- ScopeID string `json:"scope_id"`
- Key string `json:"key"`
- Value map[string]interface{} `json:"value"`
- Version int `json:"version"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- SettingListData {
- Pagination PaginationData `json:"pagination"`
- List []SettingData `json:"list"`
- }
-
- PaginationData {
- Total int64 `json:"total"`
- Page int64 `json:"page"`
- PageSize int64 `json:"pageSize"`
- TotalPages int64 `json:"totalPages"`
- }
-)
-
-@server(
- group: setting
- prefix: /api/v1/settings
- middleware: AuthJWT
- tags: "Setting - General"
- summary: "Manage settings by scope, scope_id, and key. Requires Bearer JWT."
-)
-service gateway {
- @handler listSettings
- get /:scope/:scope_id (SettingPath) returns (SettingListData)
-
- @handler getSetting
- get /:scope/:scope_id/:key (SettingKeyPath) returns (SettingData)
-
- @handler upsertSetting
- put /:scope/:scope_id/:key (SettingUpsertReq) returns (SettingData)
-
- @handler deleteSetting
- delete /:scope/:scope_id/:key (SettingKeyPath)
-}
diff --git a/old/backend/generate/api/threads_account.api b/old/backend/generate/api/threads_account.api
deleted file mode 100644
index dec95a2..0000000
--- a/old/backend/generate/api/threads_account.api
+++ /dev/null
@@ -1,1043 +0,0 @@
-syntax = "v1"
-
-type (
- ThreadsAccountData {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- Username string `json:"username,omitempty"`
- ThreadsUserID string `json:"threads_user_id,omitempty"`
- PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
- BrowserConnected bool `json:"browser_connected"`
- ApiConnected bool `json:"api_connected"`
- APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"`
- Status string `json:"status"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ListThreadsAccountsData {
- List []ThreadsAccountData `json:"list"`
- ActiveAccountID string `json:"active_account_id"`
- }
-
- DeleteThreadsAccountData {
- DeletedID string `json:"deleted_id"`
- ActiveAccountID string `json:"active_account_id,omitempty"`
- Message string `json:"message"`
- }
-
- CreateThreadsAccountReq {
- DisplayName string `json:"display_name,optional"`
- Activate *bool `json:"activate,optional"`
- }
-
- UpdateThreadsAccountReq {
- DisplayName *string `json:"display_name,optional"`
- PersonaID *string `json:"persona_id,optional"` // deprecated: use persona_id in publish payload instead
- }
-
- ThreadsAccountPath {
- ID string `path:"id" validate:"required"`
- }
-
- ThreadsAccountConnectionPrefs {
- SearchViaApi bool `json:"search_via_api"`
- SearchSourceMode string `json:"search_source_mode"`
- PublishViaApi bool `json:"publish_via_api"`
- DevMode bool `json:"dev_mode"`
- ScrapeReplies bool `json:"scrape_replies"`
- RepliesPerPost int `json:"replies_per_post"`
- PublishHeaded bool `json:"publish_headed"`
- PlaywrightDebug bool `json:"playwright_debug"`
- }
-
- ThreadsAccountConnectionData {
- AccountID string `json:"account_id"`
- AccountName string `json:"account_name"`
- Username string `json:"username,omitempty"`
- BrowserConnected bool `json:"browser_connected"`
- ApiConnected bool `json:"api_connected"`
- Prefs ThreadsAccountConnectionPrefs `json:"prefs"`
- }
-
- UpdateThreadsAccountConnectionReq {
- SearchViaApi *bool `json:"search_via_api,optional"`
- SearchSourceMode *string `json:"search_source_mode,optional"`
- PublishViaApi *bool `json:"publish_via_api,optional"`
- DevMode *bool `json:"dev_mode,optional"`
- ScrapeReplies *bool `json:"scrape_replies,optional"`
- RepliesPerPost *int `json:"replies_per_post,optional"`
- PublishHeaded *bool `json:"publish_headed,optional"`
- PlaywrightDebug *bool `json:"playwright_debug,optional"`
- }
-
- ImportThreadsAccountSessionReq {
- StorageState string `json:"storageState" validate:"required"`
- }
-
- ImportThreadsAccountSessionData {
- Success bool `json:"success"`
- Valid bool `json:"valid"`
- Synced bool `json:"synced"`
- AccountID string `json:"account_id"`
- Username string `json:"username,omitempty"`
- Message string `json:"message"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ThreadsAccountAiSettingsData {
- AccountID string `json:"account_id"`
- Provider string `json:"provider"`
- Model string `json:"model"`
- ResearchProvider string `json:"research_provider,omitempty"`
- ResearchModel string `json:"research_model,omitempty"`
- ApiKeys map[string]string `json:"api_keys"`
- ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
- }
-
- UpdateThreadsAccountAiSettingsReq {
- Provider *string `json:"provider,optional"`
- Model *string `json:"model,optional"`
- ResearchProvider *string `json:"research_provider,optional"`
- ResearchModel *string `json:"research_model,optional"`
- ApiKeys map[string]string `json:"api_keys,optional"`
- }
-
- UpdateThreadsAccountHandlerReq {
- ThreadsAccountPath
- UpdateThreadsAccountReq
- }
-
- UpdateThreadsAccountConnectionHandlerReq {
- ThreadsAccountPath
- UpdateThreadsAccountConnectionReq
- }
-
- ImportThreadsAccountSessionHandlerReq {
- ThreadsAccountPath
- ImportThreadsAccountSessionReq
- }
-
- UpdateThreadsAccountAiSettingsHandlerReq {
- ThreadsAccountPath
- UpdateThreadsAccountAiSettingsReq
- }
-
- ThreadsAccountAiProviderPath {
- ID string `path:"id" validate:"required"`
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
- }
-
- ListThreadsAccountAiProviderModelsReq {
- ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key
- }
-
- ThreadsAccountAiProviderModelsHandlerReq {
- ThreadsAccountAiProviderPath
- ListThreadsAccountAiProviderModelsReq
- }
-
- ThreadsAccountAiProviderModelsData {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,optional"`
- }
-
- StartThreadsOAuthQuery {
- AccountID string `form:"account_id,optional"`
- }
-
- StartThreadsOAuthData {
- AuthorizeURL string `json:"authorize_url"`
- State string `json:"state"`
- AccountID string `json:"account_id"`
- }
-
- ThreadsOAuthCallbackQuery {
- 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"`
- }
-
- ThreadsOAuthLogItem {
- At int64 `json:"at"`
- Level string `json:"level"`
- Stage string `json:"stage"`
- AccountID string `json:"account_id,omitempty"`
- Message string `json:"message"`
- }
-
- ListThreadsOAuthLogsData {
- List []ThreadsOAuthLogItem `json:"list"`
- }
-
- ThreadsOAuthConfigData {
- RedirectURI string `json:"redirect_uri"`
- SuccessRedirect string `json:"success_redirect"`
- Enabled bool `json:"enabled"`
- RequiresHTTPS bool `json:"requires_https"`
- }
-
- ThreadsAPISmokeTestItem {
- Scope string `json:"scope"`
- Name string `json:"name"`
- Status string `json:"status"`
- Message string `json:"message"`
- Count int `json:"count"`
- }
-
- RunThreadsAPISmokeTestData {
- List []ThreadsAPISmokeTestItem `json:"list"`
- Passed int `json:"passed"`
- Failed int `json:"failed"`
- Skipped int `json:"skipped"`
- Total int `json:"total"`
- }
-
- RunThreadsAPIPlaygroundReq {
- Action string `json:"action" validate:"required"`
- Query string `json:"query,optional"`
- Username string `json:"username,optional"`
- MediaID string `json:"media_id,optional"`
- ReplyID string `json:"reply_id,optional"`
- ReplyToID string `json:"reply_to_id,optional"`
- Text string `json:"text,optional"`
- ImageKey string `json:"image_key,optional"`
- ImageKeys []string `json:"image_keys,optional"`
- Permalink string `json:"permalink,optional"`
- HintText string `json:"hint_text,optional"`
- SkipPrime bool `json:"skip_prime,optional"`
- LocationQuery string `json:"location_query,optional"`
- Limit int `json:"limit,optional"`
- Hide bool `json:"hide,optional"`
- SearchType string `json:"search_type,optional"`
- }
-
- RunThreadsAPIPlaygroundHandlerReq {
- ThreadsAccountPath
- RunThreadsAPIPlaygroundReq
- }
-
- ThreadsAPIPlaygroundData {
- Action string `json:"action"`
- Ok bool `json:"ok"`
- Message string `json:"message"`
- Data string `json:"data,optional"`
- }
-
- ListThreadsPostPerformanceQuery {
- Limit int `form:"limit,optional"`
- }
-
- ListThreadsPostPerformanceHandlerReq {
- ThreadsAccountPath
- ListThreadsPostPerformanceQuery
- }
-
- ThreadsPostMetricCapability {
- Scope string `json:"scope"`
- Name string `json:"name"`
- Metrics []string `json:"metrics"`
- Trackable bool `json:"trackable"`
- Note string `json:"note,optional"`
- }
-
- ThreadsInsightMetricItem {
- Name string `json:"name"`
- Value int `json:"value"`
- }
-
- ThreadsAccountInsightsBlock {
- Status string `json:"status"`
- Message string `json:"message,optional"`
- Metrics []ThreadsInsightMetricItem `json:"metrics,optional"`
- }
-
- ThreadsPostPerformanceItem {
- MediaID string `json:"media_id"`
- Text string `json:"text,optional"`
- Permalink string `json:"permalink,optional"`
- Timestamp string `json:"timestamp,optional"`
- MediaType string `json:"media_type,optional"`
- MediaURL string `json:"media_url,optional"`
- ThumbnailURL string `json:"thumbnail_url,optional"`
- TopicTag string `json:"topic_tag,optional"`
- Shortcode string `json:"shortcode,optional"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Views int `json:"views,optional"`
- Shares int `json:"shares,optional"`
- InsightsStatus string `json:"insights_status,optional"`
- InsightsMessage string `json:"insights_message,optional"`
- }
-
- ThreadsPostPerformanceData {
- AccountID string `json:"account_id"`
- Username string `json:"username,optional"`
- ThreadsUserID string `json:"threads_user_id,optional"`
- FetchedAt int64 `json:"fetched_at"`
- Capabilities []ThreadsPostMetricCapability `json:"capabilities"`
- AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"`
- Posts []ThreadsPostPerformanceItem `json:"posts"`
- }
-
- PublishQueueItemPath {
- ID string `path:"id" validate:"required"`
- QID string `path:"qid" validate:"required"`
- }
-
- ListPublishQueueQuery {
- Status string `form:"status,optional"`
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
- }
-
- ListPublishQueueHandlerReq {
- ThreadsAccountPath
- ListPublishQueueQuery
- }
-
- CreatePublishQueueReq {
- Text string `json:"text,optional"`
- ImageKey string `json:"image_key,optional"`
- ImageKeys []string `json:"image_keys,optional"`
- TopicTag string `json:"topic_tag,optional"`
- ScheduledAt int64 `json:"scheduled_at,optional"`
- }
-
- CreatePublishQueueHandlerReq {
- ThreadsAccountPath
- CreatePublishQueueReq
- }
-
- PatchPublishQueueReq {
- Text *string `json:"text,optional"`
- ImageKey *string `json:"image_key,optional"`
- ImageKeys *[]string `json:"image_keys,optional"`
- TopicTag *string `json:"topic_tag,optional"`
- ScheduledAt *int64 `json:"scheduled_at,optional"`
- }
-
- PatchPublishQueueHandlerReq {
- PublishQueueItemPath
- PatchPublishQueueReq
- }
-
- PublishQueueItemData {
- ID string `json:"id"`
- AccountID string `json:"account_id"`
- PersonaID string `json:"persona_id,omitempty"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- CopyDraftID string `json:"copy_draft_id,omitempty"`
- Text string `json:"text"`
- ImageKey string `json:"image_key,omitempty"`
- ImageKeys []string `json:"image_keys,omitempty"`
- TopicTag string `json:"topic_tag,omitempty"`
- ScheduledAt int64 `json:"scheduled_at"`
- Status string `json:"status"`
- MediaID string `json:"media_id,optional"`
- Permalink string `json:"permalink,optional"`
- PublishedAt int64 `json:"published_at,optional"`
- ErrorMessage string `json:"error_message,optional"`
- RetryCount int `json:"retry_count,omitempty"`
- LastAttemptAt int64 `json:"last_attempt_at,omitempty"`
- NextAttemptAt int64 `json:"next_attempt_at,omitempty"`
- MissedAt int64 `json:"missed_at,omitempty"`
- PausedReason string `json:"paused_reason,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- PublishQueueListData {
- Pagination PaginationData `json:"pagination"`
- List []PublishQueueItemData `json:"list"`
- }
-
- ListThreadsPublishHealthQuery {
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
- }
-
- ListThreadsPublishHealthHandlerReq {
- ThreadsAccountPath
- ListThreadsPublishHealthQuery
- }
-
- ThreadsPublishHealthSummary {
- PendingScheduled int64 `json:"pending_scheduled"`
- FailedCount int64 `json:"failed_count"`
- Published7d int64 `json:"published_7d"`
- TotalLikes7d int `json:"total_likes_7d"`
- TotalReplies7d int `json:"total_replies_7d"`
- }
-
- ThreadsPublishHealthCheckpoint {
- Checkpoint string `json:"checkpoint"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count"`
- QuoteCount int `json:"quote_count"`
- Views int `json:"views,optional"`
- CheckedAt int64 `json:"checked_at"`
- }
-
- ThreadsPublishHealthItem {
- MediaID string `json:"media_id"`
- Permalink string `json:"permalink,optional"`
- Text string `json:"text,optional"`
- PublishedAt int64 `json:"published_at"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Views int `json:"views,optional"`
- Shares int `json:"shares,optional"`
- Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"`
- }
-
- ThreadsPublishHealthData {
- Summary ThreadsPublishHealthSummary `json:"summary"`
- Pagination PaginationData `json:"pagination"`
- List []ThreadsPublishHealthItem `json:"list"`
- }
-
- PublishSlotData {
- Weekday int `json:"weekday"`
- Time string `json:"time"`
- }
-
- PublishSourceRefData {
- Type string `json:"type"`
- PersonaID string `json:"persona_id,omitempty"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- ScanPostID string `json:"scan_post_id,omitempty"`
- ManualSeed string `json:"manual_seed,omitempty"`
- }
-
- PublishInventoryPolicyData {
- AccountID string `json:"account_id"`
- Enabled bool `json:"enabled"`
- TargetDailyCount int `json:"target_daily_count"`
- LowStockThreshold int `json:"low_stock_threshold"`
- LookaheadDays int `json:"lookahead_days"`
- Timezone string `json:"timezone"`
- Slots []PublishSlotData `json:"slots"`
- SourceRefs []PublishSourceRefData `json:"source_refs"`
- UpdateAt int64 `json:"update_at"`
- }
-
- UpsertPublishInventoryPolicyReq {
- Enabled bool `json:"enabled"`
- TargetDailyCount int `json:"target_daily_count,optional"`
- LowStockThreshold int `json:"low_stock_threshold,optional"`
- LookaheadDays int `json:"lookahead_days,optional"`
- Timezone string `json:"timezone,optional"`
- Slots []PublishSlotData `json:"slots,optional"`
- SourceRefs []PublishSourceRefData `json:"source_refs,optional"`
- }
-
- UpsertPublishInventoryPolicyHandlerReq {
- ThreadsAccountPath
- UpsertPublishInventoryPolicyReq
- }
-
-
- PublishSlotInsightData {
- Weekday int `json:"weekday"`
- Time string `json:"time"`
- AvgLikes1h int `json:"avg_likes_1h"`
- AvgReplies1h int `json:"avg_replies_1h"`
- AvgLikes24h int `json:"avg_likes_24h"`
- AvgReplies24h int `json:"avg_replies_24h"`
- AvgLikes7d int `json:"avg_likes_7d"`
- AvgReplies7d int `json:"avg_replies_7d"`
- SampleSize int `json:"sample_size"`
- Recommendation string `json:"recommendation"`
- }
-
- PublishSlotInsightsData {
- AccountID string `json:"account_id"`
- Timezone string `json:"timezone"`
- Slots []PublishSlotInsightData `json:"slots"`
- }
-
- PublishGuardPolicyData {
- AccountID string `json:"account_id"`
- Enabled bool `json:"enabled"`
- MaxDailyPosts int `json:"max_daily_posts"`
- MinIntervalMinutes int `json:"min_interval_minutes"`
- ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"`
- Paused bool `json:"paused"`
- PausedReason string `json:"paused_reason,omitempty"`
- UpdateAt int64 `json:"update_at"`
- }
-
- UpsertPublishGuardPolicyReq {
- Enabled bool `json:"enabled"`
- MaxDailyPosts int `json:"max_daily_posts,optional"`
- MinIntervalMinutes int `json:"min_interval_minutes,optional"`
- ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"`
- Paused *bool `json:"paused,optional"`
- PausedReason string `json:"paused_reason,optional"`
- }
-
- UpsertPublishGuardPolicyHandlerReq {
- ThreadsAccountPath
- UpsertPublishGuardPolicyReq
- }
-
- PublishQueueEventData {
- ID string `json:"id"`
- QueueID string `json:"queue_id"`
- EventType string `json:"event_type"`
- FromStatus string `json:"from_status,omitempty"`
- ToStatus string `json:"to_status,omitempty"`
- Message string `json:"message,omitempty"`
- CreateAt int64 `json:"create_at"`
- }
-
- PublishQueueEventsData {
- List []PublishQueueEventData `json:"list"`
- }
-
- PublishAlertData {
- Type string `json:"type"`
- Severity string `json:"severity"`
- Message string `json:"message"`
- QueueID string `json:"queue_id,omitempty"`
- AccountID string `json:"account_id"`
- CreateAt int64 `json:"create_at"`
- }
-
- PublishAlertsData {
- List []PublishAlertData `json:"list"`
- }
-
- PublishQueueRangeQuery {
- StartAt int64 `form:"startAt,optional"`
- EndAt int64 `form:"endAt,optional"`
- Status string `form:"status,optional"`
- }
-
- PublishQueueRangeHandlerReq {
- ThreadsAccountPath
- PublishQueueRangeQuery
- }
-
- PublishDashboardAccountSummary {
- AccountID string `json:"account_id"`
- AccountName string `json:"account_name"`
- PendingScheduled int64 `json:"pending_scheduled"`
- FailedCount int64 `json:"failed_count"`
- Published7d int64 `json:"published_7d"`
- TotalLikes7d int `json:"total_likes_7d"`
- TotalReplies7d int `json:"total_replies_7d"`
- Paused bool `json:"paused"`
- BestSlot string `json:"best_slot,omitempty"`
- LowSlot string `json:"low_slot,omitempty"`
- }
-
- PublishDashboardSummaryData {
- List []PublishDashboardAccountSummary `json:"list"`
- TotalPending int64 `json:"total_pending"`
- TotalFailed int64 `json:"total_failed"`
- TotalPublished7d int64 `json:"total_published_7d"`
- TotalLikes7d int `json:"total_likes_7d"`
- TotalReplies7d int `json:"total_replies_7d"`
- }
-
- GenerateOwnPostReplyDraftReq {
- MediaID string `json:"media_id"`
- PersonaID string `json:"persona_id,optional"`
- ReplyToID string `json:"reply_to_id,optional"`
- PostText string `json:"post_text,optional"`
- ReplyText string `json:"reply_text,optional"`
- ThreadPostText string `json:"thread_post_text,optional"`
- ParentReplyText string `json:"parent_reply_text,optional"`
- }
-
- GenerateOwnPostReplyDraftHandlerReq {
- ThreadsAccountPath
- GenerateOwnPostReplyDraftReq
- }
-
- GenerateOwnPostReplyDraftData {
- Text string `json:"text"`
- }
-
- MentionInboxItemData {
- MediaID string `json:"media_id"`
- AuthorUsername string `json:"author_username,omitempty"`
- Text string `json:"text,omitempty"`
- Permalink string `json:"permalink,omitempty"`
- Shortcode string `json:"shortcode,omitempty"`
- Timestamp string `json:"timestamp,omitempty"`
- MediaType string `json:"media_type,omitempty"`
- IsReply bool `json:"is_reply"`
- IsQuotePost bool `json:"is_quote_post"`
- HasReplies bool `json:"has_replies"`
- RootPostID string `json:"root_post_id,omitempty"`
- ParentID string `json:"parent_id,omitempty"`
- ThreadPostText string `json:"thread_post_text,omitempty"`
- ParentReplyText string `json:"parent_reply_text,omitempty"`
- ReplyReady bool `json:"reply_ready"`
- ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
- SyncedAt int64 `json:"synced_at"`
- }
-
- SyncMentionInboxReq {
- Limit int `json:"limit,optional"`
- MaxPages int `json:"max_pages,optional"`
- }
-
- SyncMentionInboxHandlerReq {
- ThreadsAccountPath
- SyncMentionInboxReq
- }
-
- SyncMentionInboxData {
- Synced int `json:"synced"`
- Ready int `json:"ready"`
- Total int64 `json:"total"`
- }
-
- ListMentionInboxQuery {
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
- }
-
- ListMentionInboxHandlerReq {
- ThreadsAccountPath
- ListMentionInboxQuery
- }
-
- ListMentionInboxData {
- List []MentionInboxItemData `json:"list"`
- Pagination PaginationData `json:"pagination"`
- ReadyTotal int64 `json:"ready_total"`
- NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
- }
-
- PublishMentionReplyReq {
- Text string `json:"text,optional"`
- ImageKey string `json:"image_key,optional"`
- ImageKeys []string `json:"image_keys,optional"`
- }
-
- UploadPublishAttachmentData {
- AttachmentKey string `json:"attachment_key"`
- PublicURL string `json:"public_url"`
- }
-
- PublishMentionReplyPath {
- ID string `path:"id" validate:"required"`
- MediaID string `path:"media_id" validate:"required"`
- }
-
- PublishMentionReplyHandlerReq {
- PublishMentionReplyPath
- PublishMentionReplyReq
- }
-
- PublishMentionReplyData {
- MediaID string `json:"media_id,omitempty"`
- Permalink string `json:"permalink,omitempty"`
- }
-
- PublishThreadsReplyHandlerReq {
- ID string `path:"id" validate:"required"`
- ReplyToID string `form:"reply_to_id" validate:"required"`
- Text string `form:"text,optional"`
- }
-
- PublishThreadsReplyData {
- MediaID string `json:"media_id,omitempty"`
- Permalink string `json:"permalink,omitempty"`
- }
-
- GenerateOwnPostFormulaReq {
- MediaID string `json:"media_id"`
- PersonaID string `json:"persona_id,optional"`
- PostText string `json:"post_text,optional"`
- TopicTag string `json:"topic_tag,optional"`
- MediaType string `json:"media_type,optional"`
- Timestamp string `json:"timestamp,optional"`
- LikeCount int `json:"like_count,optional"`
- ReplyCount int `json:"reply_count,optional"`
- Views int `json:"views,optional"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Shares int `json:"shares,optional"`
- Force bool `json:"force,optional"`
- }
-
- GenerateOwnPostFormulaHandlerReq {
- ThreadsAccountPath
- GenerateOwnPostFormulaReq
- }
-
- OwnPostFormulaReviewData {
- MediaID string `json:"media_id"`
- ReviewedAt int64 `json:"reviewed_at"`
- FromCache bool `json:"from_cache,omitempty"`
- Summary string `json:"summary"`
- Wins []string `json:"wins"`
- Improvements []string `json:"improvements"`
- Formula string `json:"formula"`
- PostTemplate string `json:"post_template"`
- HookPattern string `json:"hook_pattern"`
- Structure string `json:"structure"`
- ReplicationTips []string `json:"replication_tips"`
- Avoid []string `json:"avoid"`
- }
-
- GenerateOwnPostFormulaData {
- OwnPostFormulaReviewData
- }
-
- ListOwnPostFormulasHandlerReq {
- ThreadsAccountPath
- }
-
- ListOwnPostFormulasData {
- List []OwnPostFormulaReviewData `json:"list"`
- }
-
- ThreadsDiagnosticsData {
- AccountID string `json:"account_id"`
- CheckedAt int64 `json:"checked_at"`
- ApiConnected bool `json:"api_connected"`
- TokenExpiresAt int64 `json:"token_expires_at,omitempty"`
- Scopes []string `json:"scopes"`
- Items []ThreadsAPISmokeTestItem `json:"items"`
- Message string `json:"message"`
- }
-
- ContentFormulaSourceMetricsData {
- LikeCount int `json:"like_count,omitempty"`
- ReplyCount int `json:"reply_count,omitempty"`
- Views int `json:"views,omitempty"`
- RepostCount int `json:"repost_count,omitempty"`
- QuoteCount int `json:"quote_count,omitempty"`
- Shares int `json:"shares,omitempty"`
- }
-
- ContentFormulaData {
- ID string `json:"id"`
- AccountID string `json:"account_id"`
- Label string `json:"label"`
- SourceType string `json:"source_type"`
- SourceRef string `json:"source_ref,omitempty"`
- SourcePostText string `json:"source_post_text,omitempty"`
- SourceAuthor string `json:"source_author,omitempty"`
- SourcePermalink string `json:"source_permalink,omitempty"`
- SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
- Summary string `json:"summary"`
- Wins []string `json:"wins,omitempty"`
- Improvements []string `json:"improvements,omitempty"`
- Formula string `json:"formula"`
- PostTemplate string `json:"post_template"`
- HookPattern string `json:"hook_pattern"`
- Structure string `json:"structure"`
- ReplicationTips []string `json:"replication_tips,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- Tags []string `json:"tags,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
- }
-
- ListContentFormulasReq {
- Page int64 `form:"page,optional"`
- PageSize int64 `form:"pageSize,optional"`
- SourceType string `form:"source_type,optional"`
- Tag string `form:"tag,optional"`
- }
-
- ListContentFormulasHandlerReq {
- ThreadsAccountPath
- ListContentFormulasReq
- }
-
- ListContentFormulasData {
- Pagination PaginationData `json:"pagination"`
- List []ContentFormulaData `json:"list"`
- }
-
- ContentFormulaItemPath {
- ID string `path:"id" validate:"required"`
- FormulaID string `path:"formulaId" validate:"required"`
- }
-
- GetContentFormulaHandlerReq {
- ContentFormulaItemPath
- }
-
- AnalyzeContentFormulaReq {
- SourceType string `json:"source_type" validate:"required"`
- PersonaID string `json:"persona_id,optional"`
- PostText string `json:"post_text,optional"`
- MediaID string `json:"media_id,optional"`
- ScanPostID string `json:"scan_post_id,optional"`
- Keyword string `json:"keyword,optional"`
- SaveLabel string `json:"save_label,optional"`
- AuthorName string `json:"author_name,optional"`
- Permalink string `json:"permalink,optional"`
- LikeCount int `json:"like_count,optional"`
- ReplyCount int `json:"reply_count,optional"`
- Views int `json:"views,optional"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Shares int `json:"shares,optional"`
- ForceRefresh bool `json:"force_refresh,optional"`
- }
-
- AnalyzeContentFormulaHandlerReq {
- ThreadsAccountPath
- AnalyzeContentFormulaReq
- }
-
- AnalyzeContentFormulaData {
- Formula ContentFormulaData `json:"formula"`
- Message string `json:"message"`
- }
-
- SearchContentFormulaPostsReq {
- Keyword string `json:"keyword" validate:"required"`
- SearchType string `json:"search_type,optional"`
- Limit int `json:"limit,optional"`
- }
-
- SearchContentFormulaPostsHandlerReq {
- ThreadsAccountPath
- SearchContentFormulaPostsReq
- }
-
- ContentFormulaSearchPostData {
- Text string `json:"text"`
- Author string `json:"author"`
- Permalink string `json:"permalink,omitempty"`
- MediaID string `json:"media_id,omitempty"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- EngagementScore int `json:"engagement_score"`
- }
-
- SearchContentFormulaPostsData {
- List []ContentFormulaSearchPostData `json:"list"`
- Message string `json:"message"`
- }
-
- PatchContentFormulaReq {
- Label *string `json:"label,optional"`
- Formula *string `json:"formula,optional"`
- PostTemplate *string `json:"post_template,optional"`
- HookPattern *string `json:"hook_pattern,optional"`
- Structure *string `json:"structure,optional"`
- Tags []string `json:"tags,optional"`
- }
-
- PatchContentFormulaHandlerReq {
- ContentFormulaItemPath
- PatchContentFormulaReq
- }
-
- DeleteContentFormulaData {
- FormulaID string `json:"formula_id"`
- Message string `json:"message"`
- }
-
- ImportOwnPostFormulaReq {
- MediaID string `json:"media_id" validate:"required"`
- Label string `json:"label,optional"`
- }
-
- ImportOwnPostFormulaHandlerReq {
- ThreadsAccountPath
- ImportOwnPostFormulaReq
- }
-)
-
-@server(
- group: threads_account
- prefix: /api/v1/threads-accounts
- middleware: AuthJWT
- tags: "ThreadsAccount"
- summary: "Threads operating account endpoints. Requires Bearer JWT."
-)
-service gateway {
- @handler listThreadsAccounts
- get / returns (ListThreadsAccountsData)
-
- @handler createThreadsAccount
- post / (CreateThreadsAccountReq) returns (ThreadsAccountData)
-
- @handler getThreadsAccount
- get /:id (ThreadsAccountPath) returns (ThreadsAccountData)
-
- @handler updateThreadsAccount
- patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
-
- @handler deleteThreadsAccount
- delete /:id (ThreadsAccountPath) returns (DeleteThreadsAccountData)
-
- @handler activateThreadsAccount
- post /:id/activate (ThreadsAccountPath)
-
- @handler getThreadsAccountConnection
- get /:id/connection (ThreadsAccountPath) returns (ThreadsAccountConnectionData)
-
- @handler updateThreadsAccountConnection
- patch /:id/connection (UpdateThreadsAccountConnectionHandlerReq) returns (ThreadsAccountConnectionData)
-
- @handler importThreadsAccountSession
- post /:id/session/import (ImportThreadsAccountSessionHandlerReq) returns (ImportThreadsAccountSessionData)
-
- @handler getThreadsAccountAiSettings
- get /:id/ai-settings (ThreadsAccountPath) returns (ThreadsAccountAiSettingsData)
-
- @handler updateThreadsAccountAiSettings
- put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
-
- @handler listThreadsAccountAiProviderModels
- post /:id/ai-settings/providers/:provider/models (ThreadsAccountAiProviderModelsHandlerReq) returns (ThreadsAccountAiProviderModelsData)
-
- @handler startThreadsOauth
- get /oauth/start (StartThreadsOAuthQuery) returns (StartThreadsOAuthData)
-
- @handler listThreadsOauthLogs
- get /oauth/logs returns (ListThreadsOAuthLogsData)
-
- @handler getThreadsOauthConfig
- get /oauth/config returns (ThreadsOAuthConfigData)
-
- @handler disconnectThreadsApi
- post /:id/api/disconnect (ThreadsAccountPath) returns (ThreadsAccountData)
-
- @handler runThreadsApiSmokeTest
- post /:id/api/smoke-test (ThreadsAccountPath) returns (RunThreadsAPISmokeTestData)
-
- @handler runThreadsApiPlayground
- post /:id/api/playground (RunThreadsAPIPlaygroundHandlerReq) returns (ThreadsAPIPlaygroundData)
-
- @handler listThreadsPostPerformance
- get /:id/post-performance (ListThreadsPostPerformanceHandlerReq) returns (ThreadsPostPerformanceData)
-
- @handler uploadPublishAttachment
- post /:id/publish-attachments (ThreadsAccountPath) returns (UploadPublishAttachmentData)
-
- @handler createPublishQueueItem
- post /:id/publish-queue (CreatePublishQueueHandlerReq) returns (PublishQueueItemData)
-
- @handler listPublishQueueItems
- get /:id/publish-queue (ListPublishQueueHandlerReq) returns (PublishQueueListData)
-
- @handler getPublishQueueItem
- get /:id/publish-queue/:qid (PublishQueueItemPath) returns (PublishQueueItemData)
-
- @handler patchPublishQueueItem
- patch /:id/publish-queue/:qid (PatchPublishQueueHandlerReq) returns (PublishQueueItemData)
-
- @handler cancelPublishQueueItem
- post /:id/publish-queue/:qid/cancel (PublishQueueItemPath) returns (PublishQueueItemData)
-
- @handler deletePublishQueueItem
- delete /:id/publish-queue/:qid (PublishQueueItemPath)
-
- @handler listThreadsPublishHealth
- get /:id/publish-health (ListThreadsPublishHealthHandlerReq) returns (ThreadsPublishHealthData)
-
- @handler getPublishInventoryPolicy
- get /:id/publish-inventory-policy (ThreadsAccountPath) returns (PublishInventoryPolicyData)
-
- @handler upsertPublishInventoryPolicy
- put /:id/publish-inventory-policy (UpsertPublishInventoryPolicyHandlerReq) returns (PublishInventoryPolicyData)
-
- @handler getPublishSlotInsights
- get /:id/publish-slot-insights (ThreadsAccountPath) returns (PublishSlotInsightsData)
-
- @handler getPublishGuardPolicy
- get /:id/publish-guard-policy (ThreadsAccountPath) returns (PublishGuardPolicyData)
-
- @handler upsertPublishGuardPolicy
- put /:id/publish-guard-policy (UpsertPublishGuardPolicyHandlerReq) returns (PublishGuardPolicyData)
-
- @handler resumePublishGuard
- post /:id/publish-guard/resume (ThreadsAccountPath) returns (PublishGuardPolicyData)
-
- @handler retryPublishQueueItem
- post /:id/publish-queue/:qid/retry (PublishQueueItemPath) returns (PublishQueueItemData)
-
- @handler listPublishQueueEvents
- get /:id/publish-queue/:qid/events (PublishQueueItemPath) returns (PublishQueueEventsData)
-
- @handler listPublishAlerts
- get /:id/publish-alerts (ThreadsAccountPath) returns (PublishAlertsData)
-
- @handler listPublishQueueRange
- get /:id/publish-calendar (PublishQueueRangeHandlerReq) returns (PublishQueueListData)
-
- @handler getThreadsDiagnostics
- get /:id/diagnostics (ThreadsAccountPath) returns (ThreadsDiagnosticsData)
-
- @handler generateOwnPostReplyDraft
- post /:id/own-post-reply-draft (GenerateOwnPostReplyDraftHandlerReq) returns (GenerateOwnPostReplyDraftData)
-
- @handler syncMentionInbox
- post /:id/mention-inbox/sync (SyncMentionInboxHandlerReq) returns (SyncMentionInboxData)
-
- @handler listMentionInbox
- get /:id/mention-inbox (ListMentionInboxHandlerReq) returns (ListMentionInboxData)
-
- @handler publishMentionReply
- post /:id/mention-inbox/:media_id/reply (PublishMentionReplyHandlerReq) returns (PublishMentionReplyData)
-
- @handler publishThreadsReply
- post /:id/publish-reply (PublishThreadsReplyHandlerReq) returns (PublishThreadsReplyData)
-
- @handler generateOwnPostFormula
- post /:id/own-post-formula (GenerateOwnPostFormulaHandlerReq) returns (GenerateOwnPostFormulaData)
-
- @handler listOwnPostFormulas
- get /:id/own-post-formulas (ListOwnPostFormulasHandlerReq) returns (ListOwnPostFormulasData)
-
- @handler getPublishDashboardSummary
- get /publish-dashboard-summary returns (PublishDashboardSummaryData)
-
- @handler listContentFormulas
- get /:id/content-formulas (ListContentFormulasHandlerReq) returns (ListContentFormulasData)
-
- @handler getContentFormula
- get /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (ContentFormulaData)
-
- @handler analyzeContentFormula
- post /:id/content-formulas/analyze (AnalyzeContentFormulaHandlerReq) returns (AnalyzeContentFormulaData)
-
- @handler searchContentFormulaPosts
- post /:id/content-formulas/search-posts (SearchContentFormulaPostsHandlerReq) returns (SearchContentFormulaPostsData)
-
- @handler patchContentFormula
- patch /:id/content-formulas/:formulaId (PatchContentFormulaHandlerReq) returns (ContentFormulaData)
-
- @handler deleteContentFormula
- delete /:id/content-formulas/:formulaId (GetContentFormulaHandlerReq) returns (DeleteContentFormulaData)
-
- @handler importOwnPostToContentFormula
- post /:id/content-formulas/import-own-post (ImportOwnPostFormulaHandlerReq) returns (AnalyzeContentFormulaData)
-}
-
-@server(
- group: threads_account
- prefix: /api/v1/threads-accounts
- tags: "ThreadsAccount"
- summary: "Threads OAuth callback (public redirect from Meta)."
-)
-service gateway {
- @handler threadsOauthCallback
- get /oauth/callback (ThreadsOAuthCallbackQuery)
-}
diff --git a/old/backend/generate/api/worker_internal.api b/old/backend/generate/api/worker_internal.api
deleted file mode 100644
index 924096a..0000000
--- a/old/backend/generate/api/worker_internal.api
+++ /dev/null
@@ -1,143 +0,0 @@
-syntax = "v1"
-
-type (
- ClaimWorkerJobReq {
- WorkerType string `json:"worker_type" validate:"required"`
- WorkerID string `json:"worker_id" validate:"required"`
- }
-
- WorkerJobPath {
- ID string `path:"id" validate:"required"`
- }
-
- WorkerJobReq {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- }
-
- WorkerHeartbeatReq {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- TTLSeconds int `json:"ttl_seconds,optional"`
- }
-
- WorkerProgressReq {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- Phase string `json:"phase,optional"`
- Summary string `json:"summary,optional"`
- Percentage int `json:"percentage,optional"`
- Steps []JobStepProgressData `json:"steps,optional"`
- }
-
- WorkerCompleteReq {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- Result map[string]interface{} `json:"result,optional"`
- }
-
- WorkerFailReq {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- Error string `json:"error" validate:"required"`
- Phase string `json:"phase,optional"`
- }
-
- WorkerCancelCheckData {
- Cancelled bool `json:"cancelled"`
- }
-
- WorkerOKData {
- OK bool `json:"ok"`
- }
-
- StorePersonaStyleProfileReq {
- ID string `path:"id" validate:"required"`
- TenantID string `json:"tenant_id" validate:"required"`
- OwnerUID string `json:"owner_uid" validate:"required"`
- StyleProfile string `json:"style_profile" validate:"required"`
- StyleBenchmark string `json:"style_benchmark,optional"`
- }
-
- StorePersonaStyleProfileData {
- ID string `json:"id"`
- UpdateAt int64 `json:"update_at"`
- }
-
- WorkerThreadsAccountSessionReq {
- ID string `path:"id" validate:"required"`
- TenantID string `json:"tenant_id" validate:"required"`
- OwnerUID string `json:"owner_uid" validate:"required"`
- }
-
- WorkerThreadsAccountSessionData {
- AccountID string `json:"account_id"`
- StorageState string `json:"storage_state"`
- UpdateAt int64 `json:"update_at"`
- }
-
- AnalyzeStyle8DPostReq {
- Text string `json:"text"`
- Permalink string `json:"permalink,optional"`
- LikeCount int `json:"like_count,optional"`
- ReplyCount int `json:"reply_count,optional"`
- }
-
- AnalyzeStyle8DReq {
- ID string `path:"id" validate:"required"`
- WorkerID string `json:"worker_id" validate:"required"`
- TenantID string `json:"tenant_id" validate:"required"`
- OwnerUID string `json:"owner_uid" validate:"required"`
- PersonaID string `json:"persona_id" validate:"required"`
- ThreadsAccountID string `json:"threads_account_id" validate:"required"`
- Username string `json:"username" validate:"required"`
- Posts []AnalyzeStyle8DPostReq `json:"posts" validate:"required"`
- Steps []JobStepProgressData `json:"steps,optional"`
- }
-
- AnalyzeStyle8DData {
- PersonaID string `json:"persona_id"`
- PostCount int `json:"post_count"`
- StyleProfile string `json:"style_profile"`
- StyleBenchmark string `json:"style_benchmark"`
- }
-)
-
-@server(
- group: job
- prefix: /api/v1/internal
- middleware: WorkerSecret
- tags: "Internal Worker"
- summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
-)
-service gateway {
- @handler claimWorkerJob
- post /workers/jobs/claim (ClaimWorkerJobReq) returns (JobData)
-
- @handler refreshWorkerJobLock
- post /workers/jobs/:id/heartbeat (WorkerHeartbeatReq) returns (WorkerOKData)
-
- @handler checkWorkerJobCancel
- post /workers/jobs/:id/cancel-check (WorkerJobReq) returns (WorkerCancelCheckData)
-
- @handler ackWorkerJobCancel
- post /workers/jobs/:id/cancel-ack (WorkerJobReq) returns (JobData)
-
- @handler updateWorkerJobProgress
- post /workers/jobs/:id/progress (WorkerProgressReq) returns (JobData)
-
- @handler completeWorkerJob
- post /workers/jobs/:id/complete (WorkerCompleteReq) returns (JobData)
-
- @handler failWorkerJob
- post /workers/jobs/:id/fail (WorkerFailReq) returns (JobData)
-
- @handler storePersonaStyleProfileFromWorker
- patch /workers/personas/:id/style-profile (StorePersonaStyleProfileReq) returns (StorePersonaStyleProfileData)
-
- @handler getWorkerThreadsAccountSession
- post /workers/threads-accounts/:id/session (WorkerThreadsAccountSessionReq) returns (WorkerThreadsAccountSessionData)
-
- @handler analyzeStyle8DFromWorker
- post /workers/jobs/:id/analyze-style8d (AnalyzeStyle8DReq) returns (AnalyzeStyle8DData)
-}
diff --git a/old/backend/generate/goctl/api/handler.tpl b/old/backend/generate/goctl/api/handler.tpl
deleted file mode 100644
index 71832a8..0000000
--- a/old/backend/generate/goctl/api/handler.tpl
+++ /dev/null
@@ -1,31 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl {{.version}}
-
-package {{.PkgName}}
-
-import (
- "net/http"
-
- "haixun-backend/internal/response"
- {{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx"
- {{end}}{{.ImportPackages}}
-)
-
-{{if .HasDoc}}{{.Doc}}{{end}}
-func {{.HandlerName}}(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- {{if .HasRequest}}var req types.{{.RequestType}}
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- {{end}}l := {{.LogicName}}.New{{.LogicType}}(r.Context(), svcCtx)
- {{if .HasResp}}data, {{end}}err := l.{{.Call}}({{if .HasRequest}}&req{{end}})
- {{if .HasResp}}response.Write(r.Context(), w, data, err){{else}}response.Write(r.Context(), w, nil, err){{end}}
- }
-}
diff --git a/old/backend/go.mod b/old/backend/go.mod
deleted file mode 100644
index b5f7b7f..0000000
--- a/old/backend/go.mod
+++ /dev/null
@@ -1,92 +0,0 @@
-module haixun-backend
-
-go 1.24
-
-require (
- github.com/aws/aws-sdk-go-v2 v1.42.1
- github.com/aws/aws-sdk-go-v2/config v1.32.28
- github.com/aws/aws-sdk-go-v2/credentials v1.19.27
- github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
- github.com/go-playground/validator/v10 v10.27.0
- github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
- github.com/golang-jwt/jwt/v4 v4.5.2
- github.com/google/uuid v1.6.0
- github.com/redis/go-redis/v9 v9.14.0
- github.com/robfig/cron/v3 v3.0.1
- github.com/zeromicro/go-zero v1.9.2
- go.mongodb.org/mongo-driver v1.17.4
- golang.org/x/crypto v0.33.0
-)
-
-require (
- github.com/andybalholm/cascadia v1.3.3 // indirect
- github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
- github.com/aws/aws-sdk-go-v2/service/signin v1.3.0 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
- github.com/aws/smithy-go v1.27.3 // indirect
- github.com/beorn7/perks v1.0.1 // indirect
- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
- github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
- github.com/fatih/color v1.18.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.8 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
- github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-playground/locales v0.14.1 // indirect
- github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
- github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
- github.com/golang/snappy v1.0.0 // indirect
- github.com/grafana/pyroscope-go v1.2.7 // indirect
- github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
- github.com/leodido/go-urn v1.4.0 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/montanaflynn/stats v0.7.1 // indirect
- github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/openzipkin/zipkin-go v0.4.3 // indirect
- github.com/pelletier/go-toml/v2 v2.2.2 // indirect
- github.com/prometheus/client_golang v1.21.1 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.62.0 // indirect
- github.com/prometheus/procfs v0.15.1 // indirect
- github.com/spaolacci/murmur3 v1.1.0 // indirect
- github.com/xdg-go/pbkdf2 v1.0.0 // indirect
- github.com/xdg-go/scram v1.1.2 // indirect
- github.com/xdg-go/stringprep v1.0.4 // indirect
- github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
- go.opentelemetry.io/otel v1.24.0 // indirect
- go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
- go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect
- go.opentelemetry.io/otel/exporters/zipkin v1.24.0 // indirect
- go.opentelemetry.io/otel/metric v1.24.0 // indirect
- go.opentelemetry.io/otel/sdk v1.24.0 // indirect
- go.opentelemetry.io/otel/trace v1.24.0 // indirect
- go.opentelemetry.io/proto/otlp v1.3.1 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
- golang.org/x/net v0.35.0 // indirect
- golang.org/x/sync v0.11.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/text v0.22.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
- google.golang.org/grpc v1.65.0 // indirect
- google.golang.org/protobuf v1.36.5 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
-)
diff --git a/old/backend/go.sum b/old/backend/go.sum
deleted file mode 100644
index a4e1d71..0000000
--- a/old/backend/go.sum
+++ /dev/null
@@ -1,289 +0,0 @@
-github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
-github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
-github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
-github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
-github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
-github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
-github.com/aws/aws-sdk-go-v2/config v1.32.28 h1:qY6afygxK5c2PPU3Sz8W6yB5W44RF1vnmPdBwViDN+Y=
-github.com/aws/aws-sdk-go-v2/config v1.32.28/go.mod h1:WeS/wN1IDs8YC+BxTrFz9ZyJ1rufRBQfirOcDusEpmQ=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.27 h1:cFksKkdaBGGmpe6XJpvrxFNWkbXY5/gwFqZNB2O9WCM=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.27/go.mod h1:20CoObBgNhFfl8/ggDQu2IZmItxDhkLcWSy4C3alDPI=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
-github.com/aws/aws-sdk-go-v2/service/signin v1.3.0 h1:i0+tbB9QBnzL5NrF2WR/zk8q2s+1N+RaDYr2627E8UI=
-github.com/aws/aws-sdk-go-v2/service/signin v1.3.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
-github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
-github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
-github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
-github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
-github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
-github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
-github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
-github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
-github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
-github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
-github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
-github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
-github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
-github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
-github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
-github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
-github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
-github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
-github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
-github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
-github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
-github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
-github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
-github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
-github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
-github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
-github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
-github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0=
-github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
-github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
-github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
-github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
-github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
-github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
-github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
-github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
-github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
-github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
-github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
-github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
-github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
-github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
-github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
-github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
-github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
-github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
-github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
-github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
-github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
-github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
-github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
-github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
-github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
-github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
-github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
-github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
-github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
-github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
-github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
-github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
-github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/zeromicro/go-zero v1.9.2 h1:ZXOXBIcazZ1pWAMiHyVnDQ3Sxwy7DYPzjE89Qtj9vqM=
-github.com/zeromicro/go-zero v1.9.2/go.mod h1:k8YBMEFZKjTd4q/qO5RCW+zDgUlNyAs5vue3P4/Kmn0=
-go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
-go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
-go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
-go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
-go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
-go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8=
-go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA=
-go.opentelemetry.io/otel/exporters/zipkin v1.24.0 h1:3evrL5poBuh1KF51D9gO/S+N/1msnm4DaBqs/rpXUqY=
-go.opentelemetry.io/otel/exporters/zipkin v1.24.0/go.mod h1:0EHgD8R0+8yRhUYJOGR8Hfg2dpiJQxDOszd5smVO9wM=
-go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
-go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
-go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
-go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
-go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
-go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
-go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
-go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
-go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
-go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
-go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
-go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
-golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
-golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
-golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
-google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
-google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
-google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
-google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
-google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
-gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
diff --git a/old/backend/internal/bootstrap/admin.go b/old/backend/internal/bootstrap/admin.go
deleted file mode 100644
index 42e3a9e..0000000
--- a/old/backend/internal/bootstrap/admin.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package bootstrap
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/member/domain/entity"
- domrepo "haixun-backend/internal/model/member/domain/repository"
-
- "github.com/google/uuid"
- "golang.org/x/crypto/bcrypt"
-)
-
-type AdminOptions struct {
- TenantID string
- Email string
- Password string
- DisplayName string
-}
-
-func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminOptions) (*entity.Member, bool, error) {
- tenantID := strings.TrimSpace(opts.TenantID)
- email := normalizeEmail(opts.Email)
- if tenantID == "" || email == "" || opts.Password == "" {
- return nil, false, app.For(code.Member).InputMissingRequired("tenant_id, email, and password are required")
- }
- if len(opts.Password) < 8 {
- return nil, false, app.For(code.Member).InputInvalidFormat("password must be at least 8 characters")
- }
-
- existing, err := repo.FindByEmail(ctx, tenantID, email)
- if err == nil {
- if err := repo.SetRoles(ctx, tenantID, existing.UID, []string{"admin"}); err != nil {
- return nil, false, err
- }
- existing.Roles = []string{"admin"}
-
- if err := bcrypt.CompareHashAndPassword([]byte(existing.PasswordHash), []byte(opts.Password)); err != nil {
- hash, hashErr := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost)
- if hashErr != nil {
- return nil, false, app.For(code.Member).SysInternal("rehash password failed").WithCause(hashErr)
- }
- if updateErr := repo.UpdatePassword(ctx, tenantID, existing.UID, string(hash)); updateErr != nil {
- return nil, false, updateErr
- }
- existing.PasswordHash = string(hash)
- }
-
- return existing, false, nil
- }
- if e := app.FromError(err); e == nil || e.Category() != code.ResNotFound {
- return nil, false, err
- }
-
- hash, err := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost)
- if err != nil {
- return nil, false, app.For(code.Member).SysInternal("hash password failed").WithCause(err)
- }
- displayName := strings.TrimSpace(opts.DisplayName)
- if displayName == "" {
- displayName = "Admin"
- }
- member, err := repo.Create(ctx, &entity.Member{
- TenantID: tenantID,
- UID: uuid.NewString(),
- Email: email,
- EmailVerified: true,
- DisplayName: displayName,
- Language: "zh-TW",
- Status: entity.StatusOpen,
- Origin: entity.OriginNative,
- PasswordHash: string(hash),
- Roles: []string{"admin"},
- })
- if err != nil {
- return nil, false, err
- }
- return member, true, nil
-}
-
-func normalizeEmail(email string) string {
- return strings.ToLower(strings.TrimSpace(email))
-}
diff --git a/old/backend/internal/bootstrap/admin_test.go b/old/backend/internal/bootstrap/admin_test.go
deleted file mode 100644
index f29d19c..0000000
--- a/old/backend/internal/bootstrap/admin_test.go
+++ /dev/null
@@ -1,244 +0,0 @@
-package bootstrap
-
-import (
- "context"
- "testing"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/member/domain/entity"
- domrepo "haixun-backend/internal/model/member/domain/repository"
-
- "golang.org/x/crypto/bcrypt"
-)
-
-type memoryMemberRepo struct {
- byEmail map[string]*entity.Member
-}
-
-func memberKey(tenantID, email string) string {
- return tenantID + ":" + normalizeEmail(email)
-}
-
-func (m *memoryMemberRepo) EnsureIndexes(context.Context) error { return nil }
-
-func (m *memoryMemberRepo) Create(_ context.Context, member *entity.Member) (*entity.Member, error) {
- if m.byEmail == nil {
- m.byEmail = map[string]*entity.Member{}
- }
- key := memberKey(member.TenantID, member.Email)
- if _, ok := m.byEmail[key]; ok {
- return nil, app.For(code.Member).ResConflict("member already exists")
- }
- cp := *member
- m.byEmail[key] = &cp
- return &cp, nil
-}
-
-func (m *memoryMemberRepo) List(_ context.Context, tenantID string, page, pageSize int64) ([]*entity.Member, int64, int64, int64, error) {
- items := make([]*entity.Member, 0)
- for _, item := range m.byEmail {
- if item.TenantID != tenantID {
- continue
- }
- cp := *item
- items = append(items, &cp)
- }
- return items, int64(len(items)), page, pageSize, nil
-}
-
-func (m *memoryMemberRepo) FindByUID(_ context.Context, tenantID, uid string) (*entity.Member, error) {
- for _, item := range m.byEmail {
- if item.TenantID == tenantID && item.UID == uid {
- cp := *item
- return &cp, nil
- }
- }
- return nil, app.For(code.Member).ResNotFound("member not found")
-}
-
-func (m *memoryMemberRepo) FindByEmail(_ context.Context, tenantID, email string) (*entity.Member, error) {
- item, ok := m.byEmail[memberKey(tenantID, email)]
- if !ok {
- return nil, app.For(code.Member).ResNotFound("member not found")
- }
- cp := *item
- return &cp, nil
-}
-
-func (m *memoryMemberRepo) UpdateProfile(context.Context, string, string, domrepo.ProfileUpdate) (*entity.Member, error) {
- return nil, app.For(code.Member).SysNotImplemented("not implemented")
-}
-
-func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID, uid, accountID string) error {
- for _, item := range m.byEmail {
- if item.TenantID == tenantID && item.UID == uid {
- item.ActiveThreadsAccountID = accountID
- return nil
- }
- }
- return app.For(code.Member).ResNotFound("member not found")
-}
-
-func (m *memoryMemberRepo) UpdatePassword(_ context.Context, tenantID, uid, passwordHash string) error {
- for _, item := range m.byEmail {
- if item.TenantID == tenantID && item.UID == uid {
- item.PasswordHash = passwordHash
- return nil
- }
- }
- return app.For(code.Member).ResNotFound("member not found")
-}
-
-func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error {
- for _, item := range m.byEmail {
- if item.TenantID == tenantID && item.UID == uid {
- item.Roles = append([]string(nil), roles...)
- return nil
- }
- }
- return app.For(code.Member).ResNotFound("member not found")
-}
-
-func (m *memoryMemberRepo) SetEmailVerificationCode(_ context.Context, tenantID, uid, verifyCode string, expiresAt int64) error {
- for _, item := range m.byEmail {
- if item.TenantID == tenantID && item.UID == uid {
- item.EmailVerifyCode = verifyCode
- item.EmailVerifyExpiresAt = expiresAt
- item.EmailVerified = false
- return nil
- }
- }
- return app.For(code.Member).ResNotFound("member not found")
-}
-
-func (m *memoryMemberRepo) ConfirmEmailVerification(_ context.Context, tenantID, uid, verifyCode string) (*entity.Member, error) {
- for _, item := range m.byEmail {
- if item.TenantID == tenantID && item.UID == uid {
- if item.EmailVerifyCode != verifyCode {
- return nil, app.For(code.Auth).AuthUnauthorized("invalid verification code")
- }
- item.EmailVerified = true
- item.EmailVerifyCode = ""
- item.EmailVerifyExpiresAt = 0
- cp := *item
- return &cp, nil
- }
- }
- return nil, app.For(code.Member).ResNotFound("member not found")
-}
-
-func TestEnsureAdminMemberCreatesAdmin(t *testing.T) {
- repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{}}
- member, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
- TenantID: "default",
- Email: "admin@haixun.local",
- Password: "Admin-Pass-1!",
- })
- if err != nil {
- t.Fatalf("EnsureAdminMember: %v", err)
- }
- if !created {
- t.Fatal("expected created=true")
- }
- if member.Roles[0] != "admin" {
- t.Fatalf("roles=%v", member.Roles)
- }
- if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil {
- t.Fatalf("password hash mismatch: %v", err)
- }
-}
-
-func TestEnsureAdminMemberUpgradesExisting(t *testing.T) {
- repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
- memberKey("default", "admin@haixun.local"): {
- TenantID: "default",
- UID: "uid-1",
- Email: "admin@haixun.local",
- Roles: []string{"user"},
- },
- }}
- _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
- TenantID: "default",
- Email: "admin@haixun.local",
- Password: "Admin-Pass-1!",
- })
- if err != nil {
- t.Fatalf("EnsureAdminMember: %v", err)
- }
- if created {
- t.Fatal("expected created=false")
- }
- member := repo.byEmail[memberKey("default", "admin@haixun.local")]
- if len(member.Roles) != 1 || member.Roles[0] != "admin" {
- t.Fatalf("roles=%v", member.Roles)
- }
- if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil {
- t.Fatalf("password should have been synced: %v", err)
- }
-}
-
-func TestEnsureAdminMemberSyncsPasswordWhenChanged(t *testing.T) {
- oldHash, err := bcrypt.GenerateFromPassword([]byte("Old-Pass-1!"), bcrypt.DefaultCost)
- if err != nil {
- t.Fatal(err)
- }
- repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
- memberKey("default", "admin@haixun.local"): {
- TenantID: "default",
- UID: "uid-1",
- Email: "admin@haixun.local",
- Roles: []string{"admin"},
- PasswordHash: string(oldHash),
- },
- }}
- _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
- TenantID: "default",
- Email: "admin@haixun.local",
- Password: "New-Pass-2@",
- })
- if err != nil {
- t.Fatalf("EnsureAdminMember: %v", err)
- }
- if created {
- t.Fatal("expected created=false")
- }
- member := repo.byEmail[memberKey("default", "admin@haixun.local")]
- if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Old-Pass-1!")); err == nil {
- t.Fatal("password should have been updated, but old hash still matches")
- }
- if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("New-Pass-2@")); err != nil {
- t.Fatalf("new password hash does not match: %v", err)
- }
-}
-
-func TestEnsureAdminMemberKeepsPasswordWhenSame(t *testing.T) {
- hash, err := bcrypt.GenerateFromPassword([]byte("Same-Pass-1!"), bcrypt.DefaultCost)
- if err != nil {
- t.Fatal(err)
- }
- repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
- memberKey("default", "admin@haixun.local"): {
- TenantID: "default",
- UID: "uid-1",
- Email: "admin@haixun.local",
- Roles: []string{"admin"},
- PasswordHash: string(hash),
- },
- }}
- _, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
- TenantID: "default",
- Email: "admin@haixun.local",
- Password: "Same-Pass-1!",
- })
- if err != nil {
- t.Fatalf("EnsureAdminMember: %v", err)
- }
- if created {
- t.Fatal("expected created=false")
- }
- member := repo.byEmail[memberKey("default", "admin@haixun.local")]
- if member.PasswordHash != string(hash) {
- t.Fatal("password hash should not change when password is the same")
- }
-}
diff --git a/old/backend/internal/bootstrap/envfile.go b/old/backend/internal/bootstrap/envfile.go
deleted file mode 100644
index f4f73c6..0000000
--- a/old/backend/internal/bootstrap/envfile.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package bootstrap
-
-import (
- "bufio"
- "os"
- "path/filepath"
- "strings"
-)
-
-// LoadInfraEnv reads local env files into process env (only keys not already set).
-// Search order: HAIXUN_ENV_FILE, deploy/.env.dev (for native dev run).
-func LoadInfraEnv() {
- if path := strings.TrimSpace(os.Getenv("HAIXUN_ENV_FILE")); path != "" {
- _ = loadEnvFile(path)
- return
- }
- cwd, err := os.Getwd()
- if err != nil {
- return
- }
- candidates := []string{
- filepath.Join(cwd, "deploy", ".env.dev"),
- filepath.Join(cwd, "..", "deploy", ".env.dev"),
- filepath.Join(cwd, "..", "..", "deploy", ".env.dev"),
- }
- for _, path := range candidates {
- if loadEnvFile(path) {
- return
- }
- }
-}
-
-func loadEnvFile(path string) bool {
- f, err := os.Open(path)
- if err != nil {
- return false
- }
- defer f.Close()
-
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- key, value, ok := strings.Cut(line, "=")
- if !ok {
- continue
- }
- key = strings.TrimSpace(key)
- if key == "" {
- continue
- }
- if _, exists := os.LookupEnv(key); exists {
- continue
- }
- value = strings.TrimSpace(value)
- if len(value) >= 2 {
- if (value[0] == '"' && value[len(value)-1] == '"') ||
- (value[0] == '\'' && value[len(value)-1] == '\'') {
- value = value[1 : len(value)-1]
- }
- }
- _ = os.Setenv(key, value)
- }
- return true
-}
diff --git a/old/backend/internal/bootstrap/envfile_test.go b/old/backend/internal/bootstrap/envfile_test.go
deleted file mode 100644
index 0f6ab1a..0000000
--- a/old/backend/internal/bootstrap/envfile_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package bootstrap
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestLoadEnvFile(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, ".env")
- content := `# comment
-HAIXUN_JWT_ACCESS_SECRET=from-file
-THREADS_APP_ID="1566509781118049"
-`
- if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
- t.Fatal(err)
- }
- t.Setenv("HAIXUN_JWT_REFRESH_SECRET", "preset")
- unset := []string{"HAIXUN_JWT_ACCESS_SECRET", "THREADS_APP_ID"}
- for _, key := range unset {
- _ = os.Unsetenv(key)
- }
- if !loadEnvFile(path) {
- t.Fatal("loadEnvFile returned false")
- }
- if got := os.Getenv("HAIXUN_JWT_ACCESS_SECRET"); got != "from-file" {
- t.Fatalf("access secret = %q", got)
- }
- if got := os.Getenv("HAIXUN_JWT_REFRESH_SECRET"); got != "preset" {
- t.Fatalf("refresh secret should stay preset, got %q", got)
- }
- if got := os.Getenv("THREADS_APP_ID"); got != "1566509781118049" {
- t.Fatalf("threads app id = %q", got)
- }
-}
diff --git a/old/backend/internal/bootstrap/init.go b/old/backend/internal/bootstrap/init.go
deleted file mode 100644
index 1911f51..0000000
--- a/old/backend/internal/bootstrap/init.go
+++ /dev/null
@@ -1,178 +0,0 @@
-package bootstrap
-
-import (
- "context"
- "fmt"
-
- "haixun-backend/internal/config"
- libcrypto "haixun-backend/internal/library/crypto"
- libmongo "haixun-backend/internal/library/mongo"
- brandrepo "haixun-backend/internal/model/brand/repository"
- contentformularepo "haixun-backend/internal/model/content_formula/repository"
- cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
- contentopsrepo "haixun-backend/internal/model/content_ops/repository"
- copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
- copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
- jobrepo "haixun-backend/internal/model/job/repository"
- jobusecase "haixun-backend/internal/model/job/usecase"
- kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
- memberrepo "haixun-backend/internal/model/member/repository"
- mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
- outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
- ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository"
- permissionrepo "haixun-backend/internal/model/permission/repository"
- permissionuc "haixun-backend/internal/model/permission/usecase"
- personarepo "haixun-backend/internal/model/persona/repository"
- placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
- publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
- publishguardrepo "haixun-backend/internal/model/publish_guard/repository"
- publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository"
- publishqueuerepo "haixun-backend/internal/model/publish_queue/repository"
- publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository"
- scanpostrepo "haixun-backend/internal/model/scan_post/repository"
- settingrepo "haixun-backend/internal/model/setting/repository"
- stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
- threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
-)
-
-type InitOptions struct {
- TenantID string
- AdminEmail string
- AdminPass string
- DisplayName string
-}
-
-type InitReport struct {
- IndexesEnsured bool
- PermissionsSeeded bool
- RolePermissionsSeeded bool
- AdminUID string
- AdminCreated bool
-}
-
-func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport, error) {
- if cfg.Mongo.URI == "" || cfg.Mongo.Database == "" {
- return nil, fmt.Errorf("mongo URI and database are required")
- }
- if opts.TenantID == "" {
- return nil, fmt.Errorf("tenant_id is required")
- }
- if opts.AdminEmail == "" || opts.AdminPass == "" {
- return nil, fmt.Errorf("admin email and password are required")
- }
-
- mongoClient, err := libmongo.NewClient(ctx, cfg.Mongo)
- if err != nil {
- return nil, fmt.Errorf("connect mongo: %w", err)
- }
- defer func() { _ = mongoClient.Close(ctx) }()
-
- db := mongoClient.Database()
- report := &InitReport{}
-
- // cipher 只用於資料加解密;EnsureIndexes 不會用到,但 secrets repo 建構子需要它。
- secretsCipher, err := libcrypto.New(cfg.Secrets.EncryptionKey)
- if err != nil {
- return nil, fmt.Errorf("init secrets cipher: %w", err)
- }
-
- settingRepository := settingrepo.NewMongoRepository(db)
- memberRepository := memberrepo.NewMongoRepository(db)
- permissionRepository := permissionrepo.NewMongoPermissionRepository(db)
- rolePermissionRepository := permissionrepo.NewMongoRolePermissionRepository(db)
- jobTemplateRepository := jobrepo.NewMongoTemplateRepository(db)
- jobRunRepository := jobrepo.NewMongoRunRepository(db)
- jobScheduleRepository := jobrepo.NewMongoScheduleRepository(db)
- jobEventRepository := jobrepo.NewMongoEventRepository(db)
-
- repos := []struct {
- name string
- fn func(context.Context) error
- }{
- {"settings", settingRepository.EnsureIndexes},
- {"members", memberRepository.EnsureIndexes},
- {"permissions", permissionRepository.EnsureIndexes},
- {"role_permissions", rolePermissionRepository.EnsureIndexes},
- {"job_templates", jobTemplateRepository.EnsureIndexes},
- {"job_runs", jobRunRepository.EnsureIndexes},
- {"job_schedules", jobScheduleRepository.EnsureIndexes},
- {"job_events", jobEventRepository.EnsureIndexes},
- {"copy_missions", copymissionrepo.NewMongoRepository(db).EnsureIndexes},
- {"copy_drafts", copydraftrepo.NewMongoRepository(db).EnsureIndexes},
- {"scan_posts", scanpostrepo.NewMongoRepository(db).EnsureIndexes},
- {"outreach_drafts", outreachdraftrepo.NewMongoRepository(db).EnsureIndexes},
- {"content_matrix", cmatrixrepo.NewMongoRepository(db).EnsureIndexes},
- {"knowledge_graph", kgrepo.NewMongoRepository(db).EnsureIndexes},
- {"personas", personarepo.NewMongoRepository(db).EnsureIndexes},
- {"brands", brandrepo.NewMongoRepository(db).EnsureIndexes},
- {"placement_topics", placementtopicrepo.NewMongoRepository(db).EnsureIndexes},
- {"threads_accounts", threadsaccountrepo.NewMongoRepository(db).EnsureIndexes},
- {"threads_account_secrets", threadsaccountrepo.NewSecretsMongoRepository(db, secretsCipher).EnsureIndexes},
- {"publish_analytics", publishanalyticsrepo.NewMongoRepository(db).EnsureIndexes},
- {"publish_queue", publishqueuerepo.NewMongoRepository(db).EnsureIndexes},
- {"publish_inventory", publishinventoryrepo.NewMongoRepository(db).EnsureIndexes},
- {"publish_guard", publishguardrepo.NewMongoRepository(db).EnsureIndexes},
- {"publish_queue_events", publishqueueeventrepo.NewMongoRepository(db).EnsureIndexes},
- {"style_presets", stylepresetrepo.NewMongoRepository(db).EnsureIndexes},
- {"own_post_formulas", ownpostformularepo.NewMongoRepository(db).EnsureIndexes},
- {"content_formulas", contentformularepo.NewMongoRepository(db).EnsureIndexes},
- {"content_ops", contentopsrepo.NewMongoRepository(db).EnsureIndexes},
- {"mention_inbox", mentioninboxrepo.NewMongoRepository(db).EnsureIndexes},
- }
- for _, repo := range repos {
- if err := repo.fn(ctx); err != nil {
- return nil, fmt.Errorf("ensure %s indexes: %w", repo.name, err)
- }
- }
- report.IndexesEnsured = true
-
- jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, nil)
- jobTemplates := []struct {
- name string
- fn func(context.Context) error
- }{
- {"demo", jobUseCase.EnsureDemoTemplate},
- {"style_8d", jobUseCase.EnsureStyle8DTemplate},
- {"expand_graph", jobUseCase.EnsureExpandGraphTemplate},
- {"placement_scan", jobUseCase.EnsurePlacementScanTemplate},
- {"scan_viral", jobUseCase.EnsureScanViralTemplate},
- {"generate_outreach_draft", jobUseCase.EnsureGenerateOutreachDraftTemplate},
- {"refresh_threads_token", jobUseCase.EnsureRefreshThreadsTokenTemplate},
- {"publish_analytics", jobUseCase.EnsurePublishAnalyticsTemplate},
- }
- for _, template := range jobTemplates {
- if err := template.fn(ctx); err != nil {
- return nil, fmt.Errorf("seed %s job template: %w", template.name, err)
- }
- }
-
- permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
- if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
- return nil, fmt.Errorf("seed permissions catalog: %w", err)
- }
- if cfg.Permission.SeedAdminRegister {
- if err := permissionUseCase.EnsureAdminRegisterPermission(ctx); err != nil {
- return nil, fmt.Errorf("seed admin register permission: %w", err)
- }
- }
- report.PermissionsSeeded = true
-
- if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, opts.TenantID); err != nil {
- return nil, fmt.Errorf("seed role permissions: %w", err)
- }
- report.RolePermissionsSeeded = true
-
- admin, created, err := EnsureAdminMember(ctx, memberRepository, AdminOptions{
- TenantID: opts.TenantID,
- Email: opts.AdminEmail,
- Password: opts.AdminPass,
- DisplayName: opts.DisplayName,
- })
- if err != nil {
- return nil, err
- }
- report.AdminUID = admin.UID
- report.AdminCreated = created
-
- return report, nil
-}
diff --git a/old/backend/internal/config/config.go b/old/backend/internal/config/config.go
deleted file mode 100644
index 5b02fd0..0000000
--- a/old/backend/internal/config/config.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package config
-
-import "github.com/zeromicro/go-zero/rest"
-
-type MongoConf struct {
- URI string
- Database string
- TimeoutSeconds int `json:",default=10"`
-}
-
-type RedisConf struct {
- Addr string `json:",optional"`
- Password string `json:",optional"`
- DB int `json:",optional"`
-}
-
-type JobWorkerConf struct {
- Enabled bool `json:",default=true"`
- WorkerType string `json:",default=go"`
- WorkerID string `json:",optional"`
-}
-
-type JobSchedulerConf struct {
- Enabled bool `json:",default=true"`
- IntervalSeconds int `json:",default=60"`
-}
-
-type JobReaperConf struct {
- Enabled bool `json:",default=true"`
- IntervalSeconds int `json:",default=30"`
-}
-
-type AuthConf struct {
- AccessSecret string `json:",optional"`
- RefreshSecret string `json:",optional"`
- AccessExpireSeconds int64 `json:",default=900"`
- RefreshExpireSeconds int64 `json:",default=2592000"`
- DevHeaderFallback bool `json:",default=false"`
-}
-
-type InternalWorkerConf struct {
- Secret string `json:",optional"`
-}
-
-type PermissionConf struct {
- // SeedAdminRegister controls the closed-beta admin-only register permission.
- // Turn this off when public registration policy is redesigned.
- SeedAdminRegister bool `json:",default=true"`
-}
-
-// SecretsConf holds the application-layer encryption key (base64 of 32 bytes)
-// used to encrypt sensitive data at rest (browser session, third-party API keys).
-type SecretsConf struct {
- EncryptionKey string `json:",optional"`
-}
-
-type S3Conf struct {
- Endpoint string `json:",optional"`
- PublicBaseURL string `json:",optional"`
- Region string `json:",default=us-east-1"`
- Bucket string `json:",optional"`
- AccessKey string `json:",optional"`
- SecretKey string `json:",optional"`
- UsePathStyle bool `json:",default=true"`
-}
-
-type StorageConf struct {
- S3 S3Conf `json:",optional"`
-}
-
-type SMTPConf struct {
- Host string `json:",optional"`
- Port int `json:",default=25"`
- Username string `json:",optional"`
- Password string `json:",optional"`
- From string `json:",optional"`
-}
-
-type BraveConf struct {
- APIKey string `json:",optional"`
-}
-
-type ThreadsOAuthConf struct {
- AppID string `json:",optional"`
- AppSecret string `json:",optional"`
- RedirectURI string `json:",optional"`
- SuccessRedirect string `json:",optional"`
-}
-
-type ThreadsTokenRefreshConf struct {
- Enabled bool `json:",default=true"`
- IntervalSeconds int `json:",default=3600"`
- LeadDays int `json:",default=7"`
-}
-
-type ThreadsPublishQueueConf struct {
- Enabled bool `json:",default=true"`
- IntervalSeconds int `json:",default=60"`
- BatchSize int `json:",default=20"`
-}
-
-type Config struct {
- rest.RestConf
- Mongo MongoConf `json:",optional"`
- Redis RedisConf `json:",optional"`
- Auth AuthConf `json:",optional"`
- Secrets SecretsConf `json:",optional"`
- Storage StorageConf `json:",optional"`
- SMTP SMTPConf `json:",optional"`
- Permission PermissionConf `json:",optional"`
- InternalWorker InternalWorkerConf `json:",optional"`
- JobWorker JobWorkerConf `json:",optional"`
- JobScheduler JobSchedulerConf `json:",optional"`
- JobReaper JobReaperConf `json:",optional"`
- Brave BraveConf `json:",optional"`
- ThreadsOAuth ThreadsOAuthConf `json:",optional"`
- ThreadsTokenRefresh ThreadsTokenRefreshConf `json:",optional"`
- ThreadsPublishQueue ThreadsPublishQueueConf `json:",optional"`
-}
diff --git a/old/backend/internal/handler/ai/chat_handler.go b/old/backend/internal/handler/ai/chat_handler.go
deleted file mode 100644
index 88123e3..0000000
--- a/old/backend/internal/handler/ai/chat_handler.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package ai
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/ai"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ChatHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AIChatReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- token, err := ai.BearerToken(r)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
-
- l := ai.NewChatLogic(r.Context(), svcCtx)
- data, err := l.Chat(&req, token)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/ai/chat_stream_handler.go b/old/backend/internal/handler/ai/chat_stream_handler.go
deleted file mode 100644
index ecc4fc3..0000000
--- a/old/backend/internal/handler/ai/chat_stream_handler.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package ai
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
-
- "haixun-backend/internal/logic/ai"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AIChatReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- token, err := ai.BearerToken(r)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
-
- l := ai.NewChatStreamLogic(r.Context(), svcCtx)
- stream, err := l.ChatStream(&req, token)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
-
- flusher, ok := w.(http.Flusher)
- if !ok {
- response.Write(r.Context(), w, nil, response.WrapRequestError(fmt.Errorf("server does not support streaming")))
- return
- }
-
- w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
- w.Header().Set("Cache-Control", "no-cache")
- w.Header().Set("Connection", "keep-alive")
- w.Header().Set("X-Accel-Buffering", "no")
-
- for event := range stream {
- writeSSE(w, event.Type, event)
- flusher.Flush()
- if event.Type == "done" || event.Type == "error" {
- return
- }
- }
- writeSSE(w, "done", map[string]string{"finish_reason": "stop"})
- flusher.Flush()
- }
-}
-
-func writeSSE(w http.ResponseWriter, eventName string, data any) {
- payload, err := json.Marshal(data)
- if err != nil {
- payload = []byte(`{"type":"error","error":"failed to serialize SSE payload"}`)
- eventName = "error"
- }
- _, _ = fmt.Fprintf(w, "event: %s\n", eventName)
- _, _ = fmt.Fprintf(w, "data: %s\n\n", payload)
-}
diff --git a/old/backend/internal/handler/ai/islander_chat_stream_handler.go b/old/backend/internal/handler/ai/islander_chat_stream_handler.go
deleted file mode 100644
index f858ef9..0000000
--- a/old/backend/internal/handler/ai/islander_chat_stream_handler.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package ai
-
-import (
- "fmt"
- "net/http"
-
- "haixun-backend/internal/logic/ai"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func IslanderChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.IslanderChatReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := ai.NewIslanderChatStreamLogic(r.Context(), svcCtx)
- stream, err := l.ChatStream(&req)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
-
- flusher, ok := w.(http.Flusher)
- if !ok {
- response.Write(r.Context(), w, nil, response.WrapRequestError(fmt.Errorf("server does not support streaming")))
- return
- }
-
- w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
- w.Header().Set("Cache-Control", "no-cache")
- w.Header().Set("Connection", "keep-alive")
- w.Header().Set("X-Accel-Buffering", "no")
-
- for event := range stream {
- writeSSE(w, event.Type, event)
- flusher.Flush()
- if event.Type == "done" || event.Type == "error" {
- return
- }
- }
- writeSSE(w, "done", map[string]string{"finish_reason": "stop"})
- flusher.Flush()
- }
-}
diff --git a/old/backend/internal/handler/ai/list_ai_provider_models_handler.go b/old/backend/internal/handler/ai/list_ai_provider_models_handler.go
deleted file mode 100644
index 5618812..0000000
--- a/old/backend/internal/handler/ai/list_ai_provider_models_handler.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package ai
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/ai"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AIProviderPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- token, err := ai.BearerToken(r)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
-
- l := ai.NewListAIProviderModelsLogic(r.Context(), svcCtx)
- data, err := l.ListAIProviderModels(&req, token)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/ai/list_ai_providers_handler.go b/old/backend/internal/handler/ai/list_ai_providers_handler.go
deleted file mode 100644
index 6397e77..0000000
--- a/old/backend/internal/handler/ai/list_ai_providers_handler.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package ai
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/ai"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListAiProvidersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := ai.NewListAIProvidersLogic(r.Context(), svcCtx)
- data, err := l.ListAIProviders()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/auth/login_handler.go b/old/backend/internal/handler/auth/login_handler.go
deleted file mode 100644
index acdd92f..0000000
--- a/old/backend/internal/handler/auth/login_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package auth
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/auth"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AuthLoginReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := auth.NewLoginLogic(r.Context(), svcCtx)
- data, err := l.Login(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/auth/logout_handler.go b/old/backend/internal/handler/auth/logout_handler.go
deleted file mode 100644
index 73ba93d..0000000
--- a/old/backend/internal/handler/auth/logout_handler.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package auth
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/auth"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := auth.NewLogoutLogic(r.Context(), svcCtx)
- data, err := l.Logout(r.Header.Get("Authorization"))
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/auth/refresh_handler.go b/old/backend/internal/handler/auth/refresh_handler.go
deleted file mode 100644
index 0c1d3f8..0000000
--- a/old/backend/internal/handler/auth/refresh_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package auth
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/auth"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func RefreshHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AuthRefreshReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := auth.NewRefreshLogic(r.Context(), svcCtx)
- data, err := l.Refresh(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/auth/register_handler.go b/old/backend/internal/handler/auth/register_handler.go
deleted file mode 100644
index d51af57..0000000
--- a/old/backend/internal/handler/auth/register_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package auth
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/auth"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AuthRegisterReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := auth.NewRegisterLogic(r.Context(), svcCtx)
- data, err := l.Register(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/auth/resend_verification_email_handler.go b/old/backend/internal/handler/auth/resend_verification_email_handler.go
deleted file mode 100644
index 0f101cd..0000000
--- a/old/backend/internal/handler/auth/resend_verification_email_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package auth
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/auth"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ResendVerificationEmailHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := auth.NewResendVerificationEmailLogic(r.Context(), svcCtx)
- data, err := l.ResendVerificationEmail()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/auth/verify_email_handler.go b/old/backend/internal/handler/auth/verify_email_handler.go
deleted file mode 100644
index 2f833ec..0000000
--- a/old/backend/internal/handler/auth/verify_email_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package auth
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/auth"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func VerifyEmailHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AuthVerifyEmailReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := auth.NewVerifyEmailLogic(r.Context(), svcCtx)
- data, err := l.VerifyEmail(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/create_brand_handler.go b/old/backend/internal/handler/brand/create_brand_handler.go
deleted file mode 100644
index 63b2053..0000000
--- a/old/backend/internal/handler/brand/create_brand_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreateBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateBrandReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewCreateBrandLogic(r.Context(), svcCtx)
- data, err := l.CreateBrand(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/create_brand_product_handler.go b/old/backend/internal/handler/brand/create_brand_product_handler.go
deleted file mode 100644
index 74f50f5..0000000
--- a/old/backend/internal/handler/brand/create_brand_product_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateBrandProductHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewCreateBrandProductLogic(r.Context(), svcCtx)
- data, err := l.CreateBrandProduct(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/delete_brand_handler.go b/old/backend/internal/handler/brand/delete_brand_handler.go
deleted file mode 100644
index bfb6e28..0000000
--- a/old/backend/internal/handler/brand/delete_brand_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BrandPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewDeleteBrandLogic(r.Context(), svcCtx)
- err := l.DeleteBrand(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/delete_brand_product_handler.go b/old/backend/internal/handler/brand/delete_brand_product_handler.go
deleted file mode 100644
index aa96e88..0000000
--- a/old/backend/internal/handler/brand/delete_brand_product_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BrandProductPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewDeleteBrandProductLogic(r.Context(), svcCtx)
- err := l.DeleteBrandProduct(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/expand_knowledge_graph_handler.go b/old/backend/internal/handler/brand/expand_knowledge_graph_handler.go
deleted file mode 100644
index 8d58dbd..0000000
--- a/old/backend/internal/handler/brand/expand_knowledge_graph_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ExpandKnowledgeGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ExpandKnowledgeGraphHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewExpandKnowledgeGraphLogic(r.Context(), svcCtx)
- data, err := l.ExpandKnowledgeGraph(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/generate_brand_content_matrix_handler.go b/old/backend/internal/handler/brand/generate_brand_content_matrix_handler.go
deleted file mode 100644
index 582ca3c..0000000
--- a/old/backend/internal/handler/brand/generate_brand_content_matrix_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GenerateBrandContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GenerateContentMatrixHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewGenerateBrandContentMatrixLogic(r.Context(), svcCtx)
- data, err := l.GenerateBrandContentMatrix(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/generate_outreach_drafts_handler.go b/old/backend/internal/handler/brand/generate_outreach_drafts_handler.go
deleted file mode 100644
index fd05413..0000000
--- a/old/backend/internal/handler/brand/generate_outreach_drafts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GenerateOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GenerateOutreachDraftsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewGenerateOutreachDraftsLogic(r.Context(), svcCtx)
- data, err := l.GenerateOutreachDrafts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/get_brand_content_matrix_handler.go b/old/backend/internal/handler/brand/get_brand_content_matrix_handler.go
deleted file mode 100644
index c880eea..0000000
--- a/old/backend/internal/handler/brand/get_brand_content_matrix_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetBrandContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BrandPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewGetBrandContentMatrixLogic(r.Context(), svcCtx)
- data, err := l.GetBrandContentMatrix(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/get_brand_handler.go b/old/backend/internal/handler/brand/get_brand_handler.go
deleted file mode 100644
index d57df63..0000000
--- a/old/backend/internal/handler/brand/get_brand_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-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.BrandPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewGetBrandLogic(r.Context(), svcCtx)
- data, err := l.GetBrand(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/get_brand_scan_schedule_handler.go b/old/backend/internal/handler/brand/get_brand_scan_schedule_handler.go
deleted file mode 100644
index beeeda7..0000000
--- a/old/backend/internal/handler/brand/get_brand_scan_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetBrandScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BrandPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewGetBrandScanScheduleLogic(r.Context(), svcCtx)
- data, err := l.GetBrandScanSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/get_knowledge_graph_handler.go b/old/backend/internal/handler/brand/get_knowledge_graph_handler.go
deleted file mode 100644
index 528c02d..0000000
--- a/old/backend/internal/handler/brand/get_knowledge_graph_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetKnowledgeGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BrandPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewGetKnowledgeGraphLogic(r.Context(), svcCtx)
- data, err := l.GetKnowledgeGraph(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/list_brand_products_handler.go b/old/backend/internal/handler/brand/list_brand_products_handler.go
deleted file mode 100644
index 8d6a49a..0000000
--- a/old/backend/internal/handler/brand/list_brand_products_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListBrandProductsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BrandPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewListBrandProductsLogic(r.Context(), svcCtx)
- data, err := l.ListBrandProducts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/list_brand_scan_posts_handler.go b/old/backend/internal/handler/brand/list_brand_scan_posts_handler.go
deleted file mode 100644
index bec71fb..0000000
--- a/old/backend/internal/handler/brand/list_brand_scan_posts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListBrandScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListBrandScanPostsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewListBrandScanPostsLogic(r.Context(), svcCtx)
- data, err := l.ListBrandScanPosts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/list_brands_handler.go b/old/backend/internal/handler/brand/list_brands_handler.go
deleted file mode 100644
index dfa249e..0000000
--- a/old/backend/internal/handler/brand/list_brands_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListBrandsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := brand.NewListBrandsLogic(r.Context(), svcCtx)
- data, err := l.ListBrands()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/patch_knowledge_graph_nodes_handler.go b/old/backend/internal/handler/brand/patch_knowledge_graph_nodes_handler.go
deleted file mode 100644
index c34f6af..0000000
--- a/old/backend/internal/handler/brand/patch_knowledge_graph_nodes_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PatchKnowledgeGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PatchKnowledgeGraphNodesHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewPatchKnowledgeGraphNodesLogic(r.Context(), svcCtx)
- data, err := l.PatchKnowledgeGraphNodes(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/patch_scan_post_outreach_handler.go b/old/backend/internal/handler/brand/patch_scan_post_outreach_handler.go
deleted file mode 100644
index ba6a6bd..0000000
--- a/old/backend/internal/handler/brand/patch_scan_post_outreach_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PatchScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PatchScanPostOutreachHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewPatchScanPostOutreachLogic(r.Context(), svcCtx)
- data, err := l.PatchScanPostOutreach(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/publish_outreach_draft_handler.go b/old/backend/internal/handler/brand/publish_outreach_draft_handler.go
deleted file mode 100644
index dc8b81e..0000000
--- a/old/backend/internal/handler/brand/publish_outreach_draft_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PublishOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishOutreachDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewPublishOutreachDraftLogic(r.Context(), svcCtx)
- data, err := l.PublishOutreachDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/start_brand_scan_job_handler.go b/old/backend/internal/handler/brand/start_brand_scan_job_handler.go
deleted file mode 100644
index ad8ee9f..0000000
--- a/old/backend/internal/handler/brand/start_brand_scan_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartBrandScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartBrandScanJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewStartBrandScanJobLogic(r.Context(), svcCtx)
- data, err := l.StartBrandScanJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/update_brand_handler.go b/old/backend/internal/handler/brand/update_brand_handler.go
deleted file mode 100644
index 443161e..0000000
--- a/old/backend/internal/handler/brand/update_brand_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateBrandHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateBrandHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewUpdateBrandLogic(r.Context(), svcCtx)
- data, err := l.UpdateBrand(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/update_brand_product_handler.go b/old/backend/internal/handler/brand/update_brand_product_handler.go
deleted file mode 100644
index 42b5be9..0000000
--- a/old/backend/internal/handler/brand/update_brand_product_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateBrandProductHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateBrandProductHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewUpdateBrandProductLogic(r.Context(), svcCtx)
- data, err := l.UpdateBrandProduct(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/update_outreach_draft_handler.go b/old/backend/internal/handler/brand/update_outreach_draft_handler.go
deleted file mode 100644
index f78e0c9..0000000
--- a/old/backend/internal/handler/brand/update_outreach_draft_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateOutreachDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewUpdateOutreachDraftLogic(r.Context(), svcCtx)
- data, err := l.UpdateOutreachDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/brand/upsert_brand_scan_schedule_handler.go b/old/backend/internal/handler/brand/upsert_brand_scan_schedule_handler.go
deleted file mode 100644
index 6910a66..0000000
--- a/old/backend/internal/handler/brand/upsert_brand_scan_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpsertBrandScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpsertBrandScanScheduleHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := brand.NewUpsertBrandScanScheduleLogic(r.Context(), svcCtx)
- data, err := l.UpsertBrandScanSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go b/old/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go
deleted file mode 100644
index 74849bb..0000000
--- a/old/backend/internal/handler/copy_mission/inspire_copy_mission_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package copy_mission
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/copy_mission"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CopyMissionInspirationHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := copy_mission.NewInspireCopyMissionLogic(r.Context(), svcCtx)
- data, err := l.InspireCopyMission(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/ack_worker_job_cancel_handler.go b/old/backend/internal/handler/job/ack_worker_job_cancel_handler.go
deleted file mode 100644
index 67aeeff..0000000
--- a/old/backend/internal/handler/job/ack_worker_job_cancel_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func AckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerJobReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewAckWorkerJobCancelLogic(r.Context(), svcCtx)
- data, err := l.AckWorkerJobCancel(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/analyze_style8_d_from_worker_handler.go b/old/backend/internal/handler/job/analyze_style8_d_from_worker_handler.go
deleted file mode 100644
index 8e1ac2b..0000000
--- a/old/backend/internal/handler/job/analyze_style8_d_from_worker_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func AnalyzeStyle8DFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AnalyzeStyle8DReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewAnalyzeStyle8DFromWorkerLogic(r.Context(), svcCtx)
- data, err := l.AnalyzeStyle8DFromWorker(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/cancel_job_handler.go b/old/backend/internal/handler/job/cancel_job_handler.go
deleted file mode 100644
index afde3d7..0000000
--- a/old/backend/internal/handler/job/cancel_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CancelJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CancelJobReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewCancelJobLogic(r.Context(), svcCtx)
- data, err := l.CancelJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/check_worker_job_cancel_handler.go b/old/backend/internal/handler/job/check_worker_job_cancel_handler.go
deleted file mode 100644
index 0027af3..0000000
--- a/old/backend/internal/handler/job/check_worker_job_cancel_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CheckWorkerJobCancelHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerJobReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewCheckWorkerJobCancelLogic(r.Context(), svcCtx)
- data, err := l.CheckWorkerJobCancel(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/claim_worker_job_handler.go b/old/backend/internal/handler/job/claim_worker_job_handler.go
deleted file mode 100644
index c7d1970..0000000
--- a/old/backend/internal/handler/job/claim_worker_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ClaimWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ClaimWorkerJobReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewClaimWorkerJobLogic(r.Context(), svcCtx)
- data, err := l.ClaimWorkerJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/complete_worker_job_handler.go b/old/backend/internal/handler/job/complete_worker_job_handler.go
deleted file mode 100644
index 93bf971..0000000
--- a/old/backend/internal/handler/job/complete_worker_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CompleteWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerCompleteReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewCompleteWorkerJobLogic(r.Context(), svcCtx)
- data, err := l.CompleteWorkerJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/create_job_handler.go b/old/backend/internal/handler/job/create_job_handler.go
deleted file mode 100644
index fe6f2dd..0000000
--- a/old/backend/internal/handler/job/create_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreateJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateJobReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewCreateJobLogic(r.Context(), svcCtx)
- data, err := l.CreateJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/create_job_schedule_handler.go b/old/backend/internal/handler/job/create_job_schedule_handler.go
deleted file mode 100644
index 3076cb3..0000000
--- a/old/backend/internal/handler/job/create_job_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreateJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateJobScheduleReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewCreateJobScheduleLogic(r.Context(), svcCtx)
- data, err := l.CreateJobSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/delete_job_schedule_handler.go b/old/backend/internal/handler/job/delete_job_schedule_handler.go
deleted file mode 100644
index ec571a2..0000000
--- a/old/backend/internal/handler/job/delete_job_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.JobScheduleIDPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewDeleteJobScheduleLogic(r.Context(), svcCtx)
- err := l.DeleteJobSchedule(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/job/disable_job_schedule_handler.go b/old/backend/internal/handler/job/disable_job_schedule_handler.go
deleted file mode 100644
index 452b586..0000000
--- a/old/backend/internal/handler/job/disable_job_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DisableJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.JobScheduleIDPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewDisableJobScheduleLogic(r.Context(), svcCtx)
- data, err := l.DisableJobSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/enable_job_schedule_handler.go b/old/backend/internal/handler/job/enable_job_schedule_handler.go
deleted file mode 100644
index 6797f47..0000000
--- a/old/backend/internal/handler/job/enable_job_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func EnableJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.JobScheduleIDPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewEnableJobScheduleLogic(r.Context(), svcCtx)
- data, err := l.EnableJobSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/fail_worker_job_handler.go b/old/backend/internal/handler/job/fail_worker_job_handler.go
deleted file mode 100644
index 2674431..0000000
--- a/old/backend/internal/handler/job/fail_worker_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func FailWorkerJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerFailReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewFailWorkerJobLogic(r.Context(), svcCtx)
- data, err := l.FailWorkerJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/get_job_handler.go b/old/backend/internal/handler/job/get_job_handler.go
deleted file mode 100644
index 45c2682..0000000
--- a/old/backend/internal/handler/job/get_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-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
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewGetJobLogic(r.Context(), svcCtx)
- data, err := l.GetJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/get_job_template_handler.go b/old/backend/internal/handler/job/get_job_template_handler.go
deleted file mode 100644
index 133d296..0000000
--- a/old/backend/internal/handler/job/get_job_template_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetJobTemplateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.JobTemplatePath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewGetJobTemplateLogic(r.Context(), svcCtx)
- data, err := l.GetJobTemplate(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/get_worker_threads_account_session_handler.go b/old/backend/internal/handler/job/get_worker_threads_account_session_handler.go
deleted file mode 100644
index ed3f8ef..0000000
--- a/old/backend/internal/handler/job/get_worker_threads_account_session_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetWorkerThreadsAccountSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerThreadsAccountSessionReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewGetWorkerThreadsAccountSessionLogic(r.Context(), svcCtx)
- data, err := l.GetWorkerThreadsAccountSession(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/list_job_events_handler.go b/old/backend/internal/handler/job/list_job_events_handler.go
deleted file mode 100644
index dcb64f0..0000000
--- a/old/backend/internal/handler/job/list_job_events_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListJobEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListJobEventsReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewListJobEventsLogic(r.Context(), svcCtx)
- data, err := l.ListJobEvents(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/list_job_schedules_handler.go b/old/backend/internal/handler/job/list_job_schedules_handler.go
deleted file mode 100644
index 27c0ef5..0000000
--- a/old/backend/internal/handler/job/list_job_schedules_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListJobSchedulesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListJobSchedulesReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewListJobSchedulesLogic(r.Context(), svcCtx)
- data, err := l.ListJobSchedules(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/list_job_templates_handler.go b/old/backend/internal/handler/job/list_job_templates_handler.go
deleted file mode 100644
index 2132c5d..0000000
--- a/old/backend/internal/handler/job/list_job_templates_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListJobTemplatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := job.NewListJobTemplatesLogic(r.Context(), svcCtx)
- data, err := l.ListJobTemplates()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/list_jobs_handler.go b/old/backend/internal/handler/job/list_jobs_handler.go
deleted file mode 100644
index 579a728..0000000
--- a/old/backend/internal/handler/job/list_jobs_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListJobsReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewListJobsLogic(r.Context(), svcCtx)
- data, err := l.ListJobs(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/refresh_worker_job_lock_handler.go b/old/backend/internal/handler/job/refresh_worker_job_lock_handler.go
deleted file mode 100644
index 14b5f4a..0000000
--- a/old/backend/internal/handler/job/refresh_worker_job_lock_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func RefreshWorkerJobLockHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerHeartbeatReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewRefreshWorkerJobLockLogic(r.Context(), svcCtx)
- data, err := l.RefreshWorkerJobLock(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/retry_job_handler.go b/old/backend/internal/handler/job/retry_job_handler.go
deleted file mode 100644
index 50c7229..0000000
--- a/old/backend/internal/handler/job/retry_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func RetryJobHandler(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
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewRetryJobLogic(r.Context(), svcCtx)
- data, err := l.RetryJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/store_persona_style_profile_from_worker_handler.go b/old/backend/internal/handler/job/store_persona_style_profile_from_worker_handler.go
deleted file mode 100644
index 9875ec5..0000000
--- a/old/backend/internal/handler/job/store_persona_style_profile_from_worker_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StorePersonaStyleProfileFromWorkerHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StorePersonaStyleProfileReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewStorePersonaStyleProfileFromWorkerLogic(r.Context(), svcCtx)
- data, err := l.StorePersonaStyleProfileFromWorker(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/update_job_schedule_handler.go b/old/backend/internal/handler/job/update_job_schedule_handler.go
deleted file mode 100644
index b7957fe..0000000
--- a/old/backend/internal/handler/job/update_job_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateJobScheduleReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewUpdateJobScheduleLogic(r.Context(), svcCtx)
- data, err := l.UpdateJobSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/update_worker_job_progress_handler.go b/old/backend/internal/handler/job/update_worker_job_progress_handler.go
deleted file mode 100644
index a46baaa..0000000
--- a/old/backend/internal/handler/job/update_worker_job_progress_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateWorkerJobProgressHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.WorkerProgressReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewUpdateWorkerJobProgressLogic(r.Context(), svcCtx)
- data, err := l.UpdateWorkerJobProgress(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/job/upsert_job_template_handler.go b/old/backend/internal/handler/job/upsert_job_template_handler.go
deleted file mode 100644
index 5e2c946..0000000
--- a/old/backend/internal/handler/job/upsert_job_template_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/job"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpsertJobTemplateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpsertJobTemplateReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := job.NewUpsertJobTemplateLogic(r.Context(), svcCtx)
- data, err := l.UpsertJobTemplate(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/get_member_ai_settings_handler.go b/old/backend/internal/handler/member/get_member_ai_settings_handler.go
deleted file mode 100644
index da32387..0000000
--- a/old/backend/internal/handler/member/get_member_ai_settings_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func GetMemberAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := member.NewGetMemberAiSettingsLogic(r.Context(), svcCtx)
- data, err := l.GetMemberAiSettings()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/get_member_capabilities_handler.go b/old/backend/internal/handler/member/get_member_capabilities_handler.go
deleted file mode 100644
index 6f8a0ea..0000000
--- a/old/backend/internal/handler/member/get_member_capabilities_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func GetMemberCapabilitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := member.NewGetMemberCapabilitiesLogic(r.Context(), svcCtx)
- data, err := l.GetMemberCapabilities()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/get_member_me_handler.go b/old/backend/internal/handler/member/get_member_me_handler.go
deleted file mode 100644
index 242229a..0000000
--- a/old/backend/internal/handler/member/get_member_me_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func GetMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := member.NewGetMemberMeLogic(r.Context(), svcCtx)
- data, err := l.GetMemberMe()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/get_member_placement_settings_handler.go b/old/backend/internal/handler/member/get_member_placement_settings_handler.go
deleted file mode 100644
index a18f8bf..0000000
--- a/old/backend/internal/handler/member/get_member_placement_settings_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func GetMemberPlacementSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := member.NewGetMemberPlacementSettingsLogic(r.Context(), svcCtx)
- data, err := l.GetMemberPlacementSettings()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/list_member_ai_provider_models_handler.go b/old/backend/internal/handler/member/list_member_ai_provider_models_handler.go
deleted file mode 100644
index 769cc2e..0000000
--- a/old/backend/internal/handler/member/list_member_ai_provider_models_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListMemberAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.MemberAiProviderModelsReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewListMemberAiProviderModelsLogic(r.Context(), svcCtx)
- data, err := l.ListMemberAiProviderModels(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/list_members_handler.go b/old/backend/internal/handler/member/list_members_handler.go
deleted file mode 100644
index 7bb0006..0000000
--- a/old/backend/internal/handler/member/list_members_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListMembersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListMembersReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewListMembersLogic(r.Context(), svcCtx)
- data, err := l.ListMembers(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/update_member_ai_settings_handler.go b/old/backend/internal/handler/member/update_member_ai_settings_handler.go
deleted file mode 100644
index 8989654..0000000
--- a/old/backend/internal/handler/member/update_member_ai_settings_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpdateMemberAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateMemberAiSettingsReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewUpdateMemberAiSettingsLogic(r.Context(), svcCtx)
- data, err := l.UpdateMemberAiSettings(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/update_member_me_handler.go b/old/backend/internal/handler/member/update_member_me_handler.go
deleted file mode 100644
index 435f4f5..0000000
--- a/old/backend/internal/handler/member/update_member_me_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateMemberMeReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewUpdateMemberMeLogic(r.Context(), svcCtx)
- data, err := l.UpdateMemberMe(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/update_member_password_handler.go b/old/backend/internal/handler/member/update_member_password_handler.go
deleted file mode 100644
index 098a335..0000000
--- a/old/backend/internal/handler/member/update_member_password_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpdateMemberPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateMemberPasswordReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewUpdateMemberPasswordLogic(r.Context(), svcCtx)
- data, err := l.UpdateMemberPassword(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/update_member_placement_settings_handler.go b/old/backend/internal/handler/member/update_member_placement_settings_handler.go
deleted file mode 100644
index 8553c20..0000000
--- a/old/backend/internal/handler/member/update_member_placement_settings_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateMemberPlacementSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateMemberPlacementSettingsReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewUpdateMemberPlacementSettingsLogic(r.Context(), svcCtx)
- data, err := l.UpdateMemberPlacementSettings(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/update_member_profile_handler.go b/old/backend/internal/handler/member/update_member_profile_handler.go
deleted file mode 100644
index ccb5651..0000000
--- a/old/backend/internal/handler/member/update_member_profile_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpdateMemberProfileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateMemberProfileReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewUpdateMemberProfileLogic(r.Context(), svcCtx)
- data, err := l.UpdateMemberProfile(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/update_member_roles_handler.go b/old/backend/internal/handler/member/update_member_roles_handler.go
deleted file mode 100644
index 6847a8b..0000000
--- a/old/backend/internal/handler/member/update_member_roles_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpdateMemberRolesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateMemberRolesReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := member.NewUpdateMemberRolesLogic(r.Context(), svcCtx)
- data, err := l.UpdateMemberRoles(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/member/upload_member_avatar_handler.go b/old/backend/internal/handler/member/upload_member_avatar_handler.go
deleted file mode 100644
index a540e0b..0000000
--- a/old/backend/internal/handler/member/upload_member_avatar_handler.go
+++ /dev/null
@@ -1,37 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "errors"
- "net/http"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-
- "haixun-backend/internal/logic/member"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-const maxAvatarUploadBytes = 5 << 20
-
-func UploadMemberAvatarHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- r.Body = http.MaxBytesReader(w, r.Body, maxAvatarUploadBytes)
- file, header, err := r.FormFile("avatar")
- if err != nil {
- if errors.As(err, new(*http.MaxBytesError)) {
- response.Write(r.Context(), w, nil, app.For(code.Member).InputInvalidFormat("avatar file must be smaller than 5MB"))
- return
- }
- response.Write(r.Context(), w, nil, app.For(code.Member).InputMissingRequired("avatar file is required"))
- return
- }
- defer file.Close()
- l := member.NewUploadMemberAvatarLogic(r.Context(), svcCtx)
- data, err := l.UploadMemberAvatar(header.Filename, header.Header.Get("Content-Type"), file)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/normal/health_handler.go b/old/backend/internal/handler/normal/health_handler.go
deleted file mode 100644
index a53f6fd..0000000
--- a/old/backend/internal/handler/normal/health_handler.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package normal
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/normal"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func HealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := normal.NewHealthLogic(r.Context(), svcCtx)
- data, err := l.Health()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/permission/get_me_permissions_handler.go b/old/backend/internal/handler/permission/get_me_permissions_handler.go
deleted file mode 100644
index 30d5fd5..0000000
--- a/old/backend/internal/handler/permission/get_me_permissions_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package permission
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/permission"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetMePermissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.MePermissionsQuery
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := permission.NewGetMePermissionsLogic(r.Context(), svcCtx)
- data, err := l.GetMePermissions(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/permission/get_permission_catalog_handler.go b/old/backend/internal/handler/permission/get_permission_catalog_handler.go
deleted file mode 100644
index 01c4f58..0000000
--- a/old/backend/internal/handler/permission/get_permission_catalog_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package permission
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/permission"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetPermissionCatalogHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PermissionCatalogQuery
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := permission.NewGetPermissionCatalogLogic(r.Context(), svcCtx)
- data, err := l.GetPermissionCatalog(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/create_content_plan_handler.go b/old/backend/internal/handler/persona/create_content_plan_handler.go
deleted file mode 100644
index 33264ab..0000000
--- a/old/backend/internal/handler/persona/create_content_plan_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func CreateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateContentPlanHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewCreateContentPlanLogic(r.Context(), svcCtx)
- data, err := l.CreateContentPlan(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/create_feedback_event_handler.go b/old/backend/internal/handler/persona/create_feedback_event_handler.go
deleted file mode 100644
index 6085fc3..0000000
--- a/old/backend/internal/handler/persona/create_feedback_event_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func CreateFeedbackEventHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateFeedbackEventHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewCreateFeedbackEventLogic(r.Context(), svcCtx)
- data, err := l.CreateFeedbackEvent(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/create_formula_pool_handler.go b/old/backend/internal/handler/persona/create_formula_pool_handler.go
deleted file mode 100644
index 209740e..0000000
--- a/old/backend/internal/handler/persona/create_formula_pool_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func CreateFormulaPoolHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateFormulaPoolHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewCreateFormulaPoolLogic(r.Context(), svcCtx)
- data, err := l.CreateFormulaPool(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/create_knowledge_source_handler.go b/old/backend/internal/handler/persona/create_knowledge_source_handler.go
deleted file mode 100644
index 5dcff2a..0000000
--- a/old/backend/internal/handler/persona/create_knowledge_source_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func CreateKnowledgeSourceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateKnowledgeSourceHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewCreateKnowledgeSourceLogic(r.Context(), svcCtx)
- data, err := l.CreateKnowledgeSource(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/create_persona_handler.go b/old/backend/internal/handler/persona/create_persona_handler.go
deleted file mode 100644
index 381b571..0000000
--- a/old/backend/internal/handler/persona/create_persona_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreatePersonaReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewCreatePersonaLogic(r.Context(), svcCtx)
- data, err := l.CreatePersona(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/delete_persona_copy_draft_handler.go b/old/backend/internal/handler/persona/delete_persona_copy_draft_handler.go
deleted file mode 100644
index 5b9c2a3..0000000
--- a/old/backend/internal/handler/persona/delete_persona_copy_draft_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func DeletePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CopyDraftPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewDeletePersonaCopyDraftLogic(r.Context(), svcCtx)
- data, err := l.DeletePersonaCopyDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/delete_persona_handler.go b/old/backend/internal/handler/persona/delete_persona_handler.go
deleted file mode 100644
index 5e33995..0000000
--- a/old/backend/internal/handler/persona/delete_persona_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeletePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewDeletePersonaLogic(r.Context(), svcCtx)
- err := l.DeletePersona(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/delete_style_preset_handler.go b/old/backend/internal/handler/persona/delete_style_preset_handler.go
deleted file mode 100644
index c7e0873..0000000
--- a/old/backend/internal/handler/persona/delete_style_preset_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func DeleteStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StylePresetPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewDeleteStylePresetLogic(r.Context(), svcCtx)
- err := l.DeleteStylePreset(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/generate_from_content_formula_handler.go b/old/backend/internal/handler/persona/generate_from_content_formula_handler.go
deleted file mode 100644
index b874afc..0000000
--- a/old/backend/internal/handler/persona/generate_from_content_formula_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GenerateFromContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GenerateFromContentFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := persona.NewGenerateFromContentFormulaLogic(r.Context(), svcCtx)
- data, err := l.GenerateFromContentFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/generate_persona_copy_draft_handler.go b/old/backend/internal/handler/persona/generate_persona_copy_draft_handler.go
deleted file mode 100644
index 66e62b8..0000000
--- a/old/backend/internal/handler/persona/generate_persona_copy_draft_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GeneratePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GeneratePersonaCopyDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewGeneratePersonaCopyDraftLogic(r.Context(), svcCtx)
- data, err := l.GeneratePersonaCopyDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go b/old/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go
deleted file mode 100644
index 3148c38..0000000
--- a/old/backend/internal/handler/persona/generate_persona_topic_matrix_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GeneratePersonaTopicMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GeneratePersonaTopicMatrixHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := persona.NewGeneratePersonaTopicMatrixLogic(r.Context(), svcCtx)
- data, err := l.GeneratePersonaTopicMatrix(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/get_persona_handler.go b/old/backend/internal/handler/persona/get_persona_handler.go
deleted file mode 100644
index 3969812..0000000
--- a/old/backend/internal/handler/persona/get_persona_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-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.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewGetPersonaLogic(r.Context(), svcCtx)
- data, err := l.GetPersona(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_content_plans_handler.go b/old/backend/internal/handler/persona/list_content_plans_handler.go
deleted file mode 100644
index 850b3c7..0000000
--- a/old/backend/internal/handler/persona/list_content_plans_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListContentPlansHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListContentPlansLogic(r.Context(), svcCtx)
- data, err := l.ListContentPlans(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_formula_pools_handler.go b/old/backend/internal/handler/persona/list_formula_pools_handler.go
deleted file mode 100644
index 6759aa6..0000000
--- a/old/backend/internal/handler/persona/list_formula_pools_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListFormulaPoolsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListFormulaPoolsLogic(r.Context(), svcCtx)
- data, err := l.ListFormulaPools(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_knowledge_chunks_handler.go b/old/backend/internal/handler/persona/list_knowledge_chunks_handler.go
deleted file mode 100644
index f4d6649..0000000
--- a/old/backend/internal/handler/persona/list_knowledge_chunks_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListKnowledgeChunksHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListKnowledgeChunksLogic(r.Context(), svcCtx)
- data, err := l.ListKnowledgeChunks(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_knowledge_sources_handler.go b/old/backend/internal/handler/persona/list_knowledge_sources_handler.go
deleted file mode 100644
index 6dca00d..0000000
--- a/old/backend/internal/handler/persona/list_knowledge_sources_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListKnowledgeSourcesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListKnowledgeSourcesLogic(r.Context(), svcCtx)
- data, err := l.ListKnowledgeSources(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_persona_content_inbox_handler.go b/old/backend/internal/handler/persona/list_persona_content_inbox_handler.go
deleted file mode 100644
index efd7e41..0000000
--- a/old/backend/internal/handler/persona/list_persona_content_inbox_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListPersonaContentInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListPersonaContentInboxHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := persona.NewListPersonaContentInboxLogic(r.Context(), svcCtx)
- data, err := l.ListPersonaContentInbox(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_persona_copy_drafts_handler.go b/old/backend/internal/handler/persona/list_persona_copy_drafts_handler.go
deleted file mode 100644
index 9a7b1bd..0000000
--- a/old/backend/internal/handler/persona/list_persona_copy_drafts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListPersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListPersonaCopyDraftsLogic(r.Context(), svcCtx)
- data, err := l.ListPersonaCopyDrafts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_persona_viral_scan_posts_handler.go b/old/backend/internal/handler/persona/list_persona_viral_scan_posts_handler.go
deleted file mode 100644
index 2418f51..0000000
--- a/old/backend/internal/handler/persona/list_persona_viral_scan_posts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListPersonaViralScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListPersonaViralScanPostsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListPersonaViralScanPostsLogic(r.Context(), svcCtx)
- data, err := l.ListPersonaViralScanPosts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_personas_handler.go b/old/backend/internal/handler/persona/list_personas_handler.go
deleted file mode 100644
index 46358e3..0000000
--- a/old/backend/internal/handler/persona/list_personas_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListPersonasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := persona.NewListPersonasLogic(r.Context(), svcCtx)
- data, err := l.ListPersonas()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_style_presets_handler.go b/old/backend/internal/handler/persona/list_style_presets_handler.go
deleted file mode 100644
index 34cfa79..0000000
--- a/old/backend/internal/handler/persona/list_style_presets_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListStylePresetsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListStylePresetsLogic(r.Context(), svcCtx)
- data, err := l.ListStylePresets(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/list_topic_candidates_handler.go b/old/backend/internal/handler/persona/list_topic_candidates_handler.go
deleted file mode 100644
index f5c56ea..0000000
--- a/old/backend/internal/handler/persona/list_topic_candidates_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListTopicCandidatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PersonaPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewListTopicCandidatesLogic(r.Context(), svcCtx)
- data, err := l.ListTopicCandidates(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go b/old/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go
deleted file mode 100644
index fd51cde..0000000
--- a/old/backend/internal/handler/persona/prune_persona_copy_drafts_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PrunePersonaCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PrunePersonaCopyDraftsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewPrunePersonaCopyDraftsLogic(r.Context(), svcCtx)
- data, err := l.PrunePersonaCopyDrafts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/publish_persona_copy_draft_handler.go b/old/backend/internal/handler/persona/publish_persona_copy_draft_handler.go
deleted file mode 100644
index 2f242e4..0000000
--- a/old/backend/internal/handler/persona/publish_persona_copy_draft_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PublishPersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishCopyDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewPublishPersonaCopyDraftLogic(r.Context(), svcCtx)
- data, err := l.PublishPersonaCopyDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go b/old/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go
deleted file mode 100644
index a799be6..0000000
--- a/old/backend/internal/handler/persona/schedule_persona_copy_draft_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func SchedulePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SchedulePersonaCopyDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := persona.NewSchedulePersonaCopyDraftLogic(r.Context(), svcCtx)
- data, err := l.SchedulePersonaCopyDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/schedule_persona_drafts_handler.go b/old/backend/internal/handler/persona/schedule_persona_drafts_handler.go
deleted file mode 100644
index a8e9471..0000000
--- a/old/backend/internal/handler/persona/schedule_persona_drafts_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func SchedulePersonaDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SchedulePersonaDraftsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewSchedulePersonaDraftsLogic(r.Context(), svcCtx)
- data, err := l.SchedulePersonaDrafts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go b/old/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go
deleted file mode 100644
index 2d0db5a..0000000
--- a/old/backend/internal/handler/persona/start_persona_formula_draft_job_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPersonaFormulaDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPersonaFormulaDraftJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewStartPersonaFormulaDraftJobLogic(r.Context(), svcCtx)
- data, err := l.StartPersonaFormulaDraftJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go b/old/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go
deleted file mode 100644
index eac2b0d..0000000
--- a/old/backend/internal/handler/persona/start_persona_rewrite_draft_job_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func StartPersonaRewriteDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPersonaRewriteDraftJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewStartPersonaRewriteDraftJobLogic(r.Context(), svcCtx)
- data, err := l.StartPersonaRewriteDraftJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go b/old/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go
deleted file mode 100644
index ae9dc93..0000000
--- a/old/backend/internal/handler/persona/start_persona_style_analysis_from_text_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPersonaStyleAnalysisFromTextHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPersonaStyleAnalysisFromTextHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewStartPersonaStyleAnalysisFromTextLogic(r.Context(), svcCtx)
- data, err := l.StartPersonaStyleAnalysisFromText(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/start_persona_style_analysis_handler.go b/old/backend/internal/handler/persona/start_persona_style_analysis_handler.go
deleted file mode 100644
index 6b0b2c1..0000000
--- a/old/backend/internal/handler/persona/start_persona_style_analysis_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPersonaStyleAnalysisHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPersonaStyleAnalysisHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewStartPersonaStyleAnalysisLogic(r.Context(), svcCtx)
- data, err := l.StartPersonaStyleAnalysis(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go b/old/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go
deleted file mode 100644
index 108a6dc..0000000
--- a/old/backend/internal/handler/persona/start_persona_topic_matrix_job_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPersonaTopicMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPersonaTopicMatrixJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewStartPersonaTopicMatrixJobLogic(r.Context(), svcCtx)
- data, err := l.StartPersonaTopicMatrixJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/start_persona_viral_scan_job_handler.go b/old/backend/internal/handler/persona/start_persona_viral_scan_job_handler.go
deleted file mode 100644
index 5f5e152..0000000
--- a/old/backend/internal/handler/persona/start_persona_viral_scan_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPersonaViralScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPersonaViralScanJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewStartPersonaViralScanJobLogic(r.Context(), svcCtx)
- data, err := l.StartPersonaViralScanJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/update_content_plan_handler.go b/old/backend/internal/handler/persona/update_content_plan_handler.go
deleted file mode 100644
index ba1bb32..0000000
--- a/old/backend/internal/handler/persona/update_content_plan_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpdateContentPlanHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateContentPlanHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewUpdateContentPlanLogic(r.Context(), svcCtx)
- data, err := l.UpdateContentPlan(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/update_persona_copy_draft_handler.go b/old/backend/internal/handler/persona/update_persona_copy_draft_handler.go
deleted file mode 100644
index 9302cb8..0000000
--- a/old/backend/internal/handler/persona/update_persona_copy_draft_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdatePersonaCopyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateCopyDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewUpdatePersonaCopyDraftLogic(r.Context(), svcCtx)
- data, err := l.UpdatePersonaCopyDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/update_persona_handler.go b/old/backend/internal/handler/persona/update_persona_handler.go
deleted file mode 100644
index d355d77..0000000
--- a/old/backend/internal/handler/persona/update_persona_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdatePersonaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdatePersonaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewUpdatePersonaLogic(r.Context(), svcCtx)
- data, err := l.UpdatePersona(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/persona/upsert_style_preset_handler.go b/old/backend/internal/handler/persona/upsert_style_preset_handler.go
deleted file mode 100644
index cfc28d6..0000000
--- a/old/backend/internal/handler/persona/upsert_style_preset_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/persona"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpsertStylePresetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpsertStylePresetHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := persona.NewUpsertStylePresetLogic(r.Context(), svcCtx)
- data, err := l.UpsertStylePreset(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go b/old/backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go
deleted file mode 100644
index 87673c7..0000000
--- a/old/backend/internal/handler/placement_topic/batch_delete_placement_topic_scan_posts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func BatchDeletePlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.BatchDeletePlacementTopicScanPostsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewBatchDeletePlacementTopicScanPostsLogic(r.Context(), svcCtx)
- data, err := l.BatchDeletePlacementTopicScanPosts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/create_placement_topic_handler.go b/old/backend/internal/handler/placement_topic/create_placement_topic_handler.go
deleted file mode 100644
index 6589fa5..0000000
--- a/old/backend/internal/handler/placement_topic/create_placement_topic_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreatePlacementTopicHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewCreatePlacementTopicLogic(r.Context(), svcCtx)
- data, err := l.CreatePlacementTopic(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/delete_placement_topic_handler.go b/old/backend/internal/handler/placement_topic/delete_placement_topic_handler.go
deleted file mode 100644
index 7c7827a..0000000
--- a/old/backend/internal/handler/placement_topic/delete_placement_topic_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeletePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PlacementTopicPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewDeletePlacementTopicLogic(r.Context(), svcCtx)
- err := l.DeletePlacementTopic(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go b/old/backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go
deleted file mode 100644
index ad8d898..0000000
--- a/old/backend/internal/handler/placement_topic/delete_placement_topic_scan_post_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeletePlacementTopicScanPostHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.DeletePlacementTopicScanPostHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewDeletePlacementTopicScanPostLogic(r.Context(), svcCtx)
- err := l.DeletePlacementTopicScanPost(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go b/old/backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go
deleted file mode 100644
index 2eb2d29..0000000
--- a/old/backend/internal/handler/placement_topic/expand_placement_topic_graph_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ExpandPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ExpandPlacementTopicGraphHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewExpandPlacementTopicGraphLogic(r.Context(), svcCtx)
- data, err := l.ExpandPlacementTopicGraph(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go b/old/backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go
deleted file mode 100644
index c645174..0000000
--- a/old/backend/internal/handler/placement_topic/generate_placement_topic_content_matrix_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GeneratePlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GeneratePlacementTopicContentMatrixHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewGeneratePlacementTopicContentMatrixLogic(r.Context(), svcCtx)
- data, err := l.GeneratePlacementTopicContentMatrix(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go b/old/backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go
deleted file mode 100644
index d980af6..0000000
--- a/old/backend/internal/handler/placement_topic/generate_placement_topic_outreach_drafts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GeneratePlacementTopicOutreachDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GeneratePlacementTopicOutreachDraftsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewGeneratePlacementTopicOutreachDraftsLogic(r.Context(), svcCtx)
- data, err := l.GeneratePlacementTopicOutreachDrafts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go b/old/backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go
deleted file mode 100644
index 1939e9c..0000000
--- a/old/backend/internal/handler/placement_topic/get_placement_topic_content_matrix_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetPlacementTopicContentMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PlacementTopicPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewGetPlacementTopicContentMatrixLogic(r.Context(), svcCtx)
- data, err := l.GetPlacementTopicContentMatrix(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go b/old/backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go
deleted file mode 100644
index ae7db4a..0000000
--- a/old/backend/internal/handler/placement_topic/get_placement_topic_graph_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetPlacementTopicGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PlacementTopicPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewGetPlacementTopicGraphLogic(r.Context(), svcCtx)
- data, err := l.GetPlacementTopicGraph(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/get_placement_topic_handler.go b/old/backend/internal/handler/placement_topic/get_placement_topic_handler.go
deleted file mode 100644
index bfcc7a6..0000000
--- a/old/backend/internal/handler/placement_topic/get_placement_topic_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetPlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PlacementTopicPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewGetPlacementTopicLogic(r.Context(), svcCtx)
- data, err := l.GetPlacementTopic(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go b/old/backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go
deleted file mode 100644
index 7783c54..0000000
--- a/old/backend/internal/handler/placement_topic/get_placement_topic_scan_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PlacementTopicPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewGetPlacementTopicScanScheduleLogic(r.Context(), svcCtx)
- data, err := l.GetPlacementTopicScanSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go b/old/backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go
deleted file mode 100644
index f294c9c..0000000
--- a/old/backend/internal/handler/placement_topic/list_placement_topic_scan_posts_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListPlacementTopicScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListPlacementTopicScanPostsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewListPlacementTopicScanPostsLogic(r.Context(), svcCtx)
- data, err := l.ListPlacementTopicScanPosts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/list_placement_topics_handler.go b/old/backend/internal/handler/placement_topic/list_placement_topics_handler.go
deleted file mode 100644
index bbbd62d..0000000
--- a/old/backend/internal/handler/placement_topic/list_placement_topics_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListPlacementTopicsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := placement_topic.NewListPlacementTopicsLogic(r.Context(), svcCtx)
- data, err := l.ListPlacementTopics()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go b/old/backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go
deleted file mode 100644
index e649322..0000000
--- a/old/backend/internal/handler/placement_topic/patch_placement_topic_graph_nodes_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PatchPlacementTopicGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PatchPlacementTopicGraphNodesHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewPatchPlacementTopicGraphNodesLogic(r.Context(), svcCtx)
- data, err := l.PatchPlacementTopicGraphNodes(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go b/old/backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go
deleted file mode 100644
index c816159..0000000
--- a/old/backend/internal/handler/placement_topic/patch_placement_topic_scan_post_outreach_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PatchPlacementTopicScanPostOutreachHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PatchPlacementTopicScanPostOutreachHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewPatchPlacementTopicScanPostOutreachLogic(r.Context(), svcCtx)
- data, err := l.PatchPlacementTopicScanPostOutreach(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go b/old/backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go
deleted file mode 100644
index c925cb8..0000000
--- a/old/backend/internal/handler/placement_topic/publish_placement_topic_outreach_draft_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PublishPlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishPlacementTopicOutreachDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewPublishPlacementTopicOutreachDraftLogic(r.Context(), svcCtx)
- data, err := l.PublishPlacementTopicOutreachDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go b/old/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go
deleted file mode 100644
index 1a763bd..0000000
--- a/old/backend/internal/handler/placement_topic/start_placement_topic_outreach_draft_job_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPlacementTopicOutreachDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPlacementTopicOutreachDraftJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewStartPlacementTopicOutreachDraftJobLogic(r.Context(), svcCtx)
- data, err := l.StartPlacementTopicOutreachDraftJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go b/old/backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go
deleted file mode 100644
index 2254b3c..0000000
--- a/old/backend/internal/handler/placement_topic/start_placement_topic_scan_job_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartPlacementTopicScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartPlacementTopicScanJobHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewStartPlacementTopicScanJobLogic(r.Context(), svcCtx)
- data, err := l.StartPlacementTopicScanJob(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/update_placement_topic_handler.go b/old/backend/internal/handler/placement_topic/update_placement_topic_handler.go
deleted file mode 100644
index 641c30e..0000000
--- a/old/backend/internal/handler/placement_topic/update_placement_topic_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdatePlacementTopicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdatePlacementTopicHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewUpdatePlacementTopicLogic(r.Context(), svcCtx)
- data, err := l.UpdatePlacementTopic(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go b/old/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go
deleted file mode 100644
index fb4bcb0..0000000
--- a/old/backend/internal/handler/placement_topic/update_placement_topic_outreach_draft_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdatePlacementTopicOutreachDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdatePlacementTopicOutreachDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewUpdatePlacementTopicOutreachDraftLogic(r.Context(), svcCtx)
- data, err := l.UpdatePlacementTopicOutreachDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go b/old/backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go
deleted file mode 100644
index 6963740..0000000
--- a/old/backend/internal/handler/placement_topic/upsert_placement_topic_scan_schedule_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/placement_topic"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpsertPlacementTopicScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpsertPlacementTopicScanScheduleHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := placement_topic.NewUpsertPlacementTopicScanScheduleLogic(r.Context(), svcCtx)
- data, err := l.UpsertPlacementTopicScanSchedule(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/register_extra.go b/old/backend/internal/handler/register_extra.go
deleted file mode 100644
index 2adfda0..0000000
--- a/old/backend/internal/handler/register_extra.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package handler
-
-import (
- "net/http"
-
- "haixun-backend/internal/handler/static"
- threads_account "haixun-backend/internal/handler/threads_account"
- "haixun-backend/internal/library/storage"
- "haixun-backend/internal/svc"
-
- "github.com/zeromicro/go-zero/rest"
-)
-
-func publicAssetReader(serverCtx *svc.ServiceContext) storage.ObjectReader {
- reader, _ := serverCtx.AvatarStorage.(storage.ObjectReader)
- return reader
-}
-
-// RegisterExtraHandlers mounts routes that are not generated by goctl.
-func RegisterExtraHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
- server.AddRoutes(
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/downloads/haixun-threads-sync.zip",
- Handler: static.ExtensionDownloadHandler(),
- },
- {
- Method: http.MethodGet,
- Path: "/public/assets",
- Handler: static.PublicAssetHandler(publicAssetReader(serverCtx)),
- },
- },
- rest.WithPrefix("/api/v1"),
- )
-
- // Legacy Meta redirect URI (deploy/.env.dev 曾誤設為 /api/threads/oauth/callback)。
- server.AddRoutes(
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/oauth/callback",
- Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx),
- },
- },
- rest.WithPrefix("/api/threads"),
- )
-}
diff --git a/old/backend/internal/handler/routes.go b/old/backend/internal/handler/routes.go
deleted file mode 100644
index 5acaf98..0000000
--- a/old/backend/internal/handler/routes.go
+++ /dev/null
@@ -1,1120 +0,0 @@
-// Code generated by goctl. DO NOT EDIT.
-// goctl 1.10.1
-
-package handler
-
-import (
- "net/http"
-
- ai "haixun-backend/internal/handler/ai"
- auth "haixun-backend/internal/handler/auth"
- brand "haixun-backend/internal/handler/brand"
- copy_mission "haixun-backend/internal/handler/copy_mission"
- job "haixun-backend/internal/handler/job"
- member "haixun-backend/internal/handler/member"
- normal "haixun-backend/internal/handler/normal"
- permission "haixun-backend/internal/handler/permission"
- persona "haixun-backend/internal/handler/persona"
- placement_topic "haixun-backend/internal/handler/placement_topic"
- setting "haixun-backend/internal/handler/setting"
- threads_account "haixun-backend/internal/handler/threads_account"
- "haixun-backend/internal/svc"
-
- "github.com/zeromicro/go-zero/rest"
-)
-
-func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
- server.AddRoutes(
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/providers",
- Handler: ai.ListAiProvidersHandler(serverCtx),
- },
- },
- rest.WithPrefix("/api/v1/ai"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.MemberAuth},
- []rest.Route{
- {
- Method: http.MethodPost,
- Path: "/chat",
- Handler: ai.ChatHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/chat/stream",
- Handler: ai.ChatStreamHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/providers/:provider/models",
- Handler: ai.ListAiProviderModelsHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/ai"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodPost,
- Path: "/islander/chat/stream",
- Handler: ai.IslanderChatStreamHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/ai"),
- )
-
- server.AddRoutes(
- []rest.Route{
- {
- Method: http.MethodPost,
- Path: "/login",
- Handler: auth.LoginHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/refresh",
- Handler: auth.RefreshHandler(serverCtx),
- },
- },
- rest.WithPrefix("/api/v1/auth"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodPost,
- Path: "/logout",
- Handler: auth.LogoutHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/register",
- Handler: auth.RegisterHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/resend-verification-email",
- Handler: auth.ResendVerificationEmailHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/verify-email",
- Handler: auth.VerifyEmailHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/auth"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/",
- Handler: brand.ListBrandsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/",
- Handler: brand.CreateBrandHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id",
- Handler: brand.GetBrandHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id",
- Handler: brand.UpdateBrandHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id",
- Handler: brand.DeleteBrandHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/content-matrix",
- Handler: brand.GetBrandContentMatrixHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-matrix/generate",
- Handler: brand.GenerateBrandContentMatrixHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/knowledge-graph",
- Handler: brand.GetKnowledgeGraphHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/knowledge-graph/expand",
- Handler: brand.ExpandKnowledgeGraphHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/knowledge-graph/nodes",
- Handler: brand.PatchKnowledgeGraphNodesHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/outreach-drafts/:draftId",
- Handler: brand.UpdateOutreachDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/outreach-drafts/generate",
- Handler: brand.GenerateOutreachDraftsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/outreach-drafts/publish",
- Handler: brand.PublishOutreachDraftHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/products",
- Handler: brand.ListBrandProductsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/products",
- Handler: brand.CreateBrandProductHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/products/:productId",
- Handler: brand.UpdateBrandProductHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id/products/:productId",
- Handler: brand.DeleteBrandProductHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/scan-jobs",
- Handler: brand.StartBrandScanJobHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/scan-posts",
- Handler: brand.ListBrandScanPostsHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/scan-posts/:postId",
- Handler: brand.PatchScanPostOutreachHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/scan-schedule",
- Handler: brand.GetBrandScanScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:id/scan-schedule",
- Handler: brand.UpsertBrandScanScheduleHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/brands"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- // Flow A copy_mission CRUD/jobs removed (stubs returned false success).
- // Keep inspire only until Post Project replaces Missions inspiration.
- {
- Method: http.MethodPost,
- Path: "/:personaId/copy-mission-inspiration",
- Handler: copy_mission.InspireCopyMissionHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/personas"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.WorkerSecret},
- []rest.Route{
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/analyze-style8d",
- Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/cancel-ack",
- Handler: job.AckWorkerJobCancelHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/cancel-check",
- Handler: job.CheckWorkerJobCancelHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/complete",
- Handler: job.CompleteWorkerJobHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/fail",
- Handler: job.FailWorkerJobHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/heartbeat",
- Handler: job.RefreshWorkerJobLockHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/:id/progress",
- Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/jobs/claim",
- Handler: job.ClaimWorkerJobHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/workers/personas/:id/style-profile",
- Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/workers/threads-accounts/:id/session",
- Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/internal"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/job/schedules",
- Handler: job.ListJobSchedulesHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/job/schedules",
- Handler: job.CreateJobScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/job/schedules/:id",
- Handler: job.UpdateJobScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/job/schedules/:id",
- Handler: job.DeleteJobScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/job/schedules/:id/disable",
- Handler: job.DisableJobScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/job/schedules/:id/enable",
- Handler: job.EnableJobScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/job/templates",
- Handler: job.ListJobTemplatesHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/job/templates/:type",
- Handler: job.GetJobTemplateHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/job/templates/:type",
- Handler: job.UpsertJobTemplateHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/jobs",
- Handler: job.CreateJobHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/jobs",
- Handler: job.ListJobsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/jobs/:id",
- Handler: job.GetJobHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/jobs/:id/cancel",
- Handler: job.CancelJobHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/jobs/:id/events",
- Handler: job.ListJobEventsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/jobs/:id/retry",
- Handler: job.RetryJobHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/",
- Handler: member.ListMembersHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:uid/password",
- Handler: member.UpdateMemberPasswordHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:uid/profile",
- Handler: member.UpdateMemberProfileHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:uid/roles",
- Handler: member.UpdateMemberRolesHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/me",
- Handler: member.GetMemberMeHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/me",
- Handler: member.UpdateMemberMeHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/me/ai-settings",
- Handler: member.GetMemberAiSettingsHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/me/ai-settings",
- Handler: member.UpdateMemberAiSettingsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/me/ai-settings/providers/:provider/models",
- Handler: member.ListMemberAiProviderModelsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/me/avatar",
- Handler: member.UploadMemberAvatarHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/me/capabilities",
- Handler: member.GetMemberCapabilitiesHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/me/placement-settings",
- Handler: member.GetMemberPlacementSettingsHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/me/placement-settings",
- Handler: member.UpdateMemberPlacementSettingsHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/members"),
- )
-
- server.AddRoutes(
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/health",
- Handler: normal.HealthHandler(serverCtx),
- },
- },
- rest.WithPrefix("/api/v1"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/catalog",
- Handler: permission.GetPermissionCatalogHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/me",
- Handler: permission.GetMePermissionsHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/permissions"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/",
- Handler: persona.ListPersonasHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/",
- Handler: persona.CreatePersonaHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id",
- Handler: persona.GetPersonaHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id",
- Handler: persona.UpdatePersonaHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id",
- Handler: persona.DeletePersonaHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-formulas/:formulaId/generate",
- Handler: persona.GenerateFromContentFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/content-inbox",
- Handler: persona.ListPersonaContentInboxHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/content-plans",
- Handler: persona.ListContentPlansHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-plans",
- Handler: persona.CreateContentPlanHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/content-plans/:contentPlanId",
- Handler: persona.UpdateContentPlanHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/copy-drafts",
- Handler: persona.ListPersonaCopyDraftsHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/copy-drafts/:draftId",
- Handler: persona.UpdatePersonaCopyDraftHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id/copy-drafts/:draftId",
- Handler: persona.DeletePersonaCopyDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/copy-drafts/:draftId/publish",
- Handler: persona.PublishPersonaCopyDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/copy-drafts/:draftId/schedule",
- Handler: persona.SchedulePersonaCopyDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/copy-drafts/generate",
- Handler: persona.GeneratePersonaCopyDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/copy-drafts/prune",
- Handler: persona.PrunePersonaCopyDraftsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/copy-drafts/schedule",
- Handler: persona.SchedulePersonaDraftsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/feedback-events",
- Handler: persona.CreateFeedbackEventHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/formula-draft/jobs",
- Handler: persona.StartPersonaFormulaDraftJobHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/formula-pools",
- Handler: persona.ListFormulaPoolsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/formula-pools",
- Handler: persona.CreateFormulaPoolHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/knowledge-chunks",
- Handler: persona.ListKnowledgeChunksHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/knowledge-sources",
- Handler: persona.CreateKnowledgeSourceHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/knowledge-sources",
- Handler: persona.ListKnowledgeSourcesHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/rewrite-draft/jobs",
- Handler: persona.StartPersonaRewriteDraftJobHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/style-analysis",
- Handler: persona.StartPersonaStyleAnalysisHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/style-analysis-from-text",
- Handler: persona.StartPersonaStyleAnalysisFromTextHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/style-presets",
- Handler: persona.ListStylePresetsHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:id/style-presets/:presetId",
- Handler: persona.UpsertStylePresetHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id/style-presets/:presetId",
- Handler: persona.DeleteStylePresetHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/topic-candidates",
- Handler: persona.ListTopicCandidatesHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/topic-matrix/generate",
- Handler: persona.GeneratePersonaTopicMatrixHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/topic-matrix/jobs",
- Handler: persona.StartPersonaTopicMatrixJobHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/viral-scan-jobs",
- Handler: persona.StartPersonaViralScanJobHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/viral-scan-posts",
- Handler: persona.ListPersonaViralScanPostsHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/personas"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/",
- Handler: placement_topic.ListPlacementTopicsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/",
- Handler: placement_topic.CreatePlacementTopicHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id",
- Handler: placement_topic.GetPlacementTopicHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id",
- Handler: placement_topic.UpdatePlacementTopicHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id",
- Handler: placement_topic.DeletePlacementTopicHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/content-matrix",
- Handler: placement_topic.GetPlacementTopicContentMatrixHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-matrix/generate",
- Handler: placement_topic.GeneratePlacementTopicContentMatrixHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/knowledge-graph",
- Handler: placement_topic.GetPlacementTopicGraphHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/knowledge-graph/expand",
- Handler: placement_topic.ExpandPlacementTopicGraphHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/knowledge-graph/nodes",
- Handler: placement_topic.PatchPlacementTopicGraphNodesHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/outreach-draft-jobs",
- Handler: placement_topic.StartPlacementTopicOutreachDraftJobHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/outreach-drafts/:draftId",
- Handler: placement_topic.UpdatePlacementTopicOutreachDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/outreach-drafts/generate",
- Handler: placement_topic.GeneratePlacementTopicOutreachDraftsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/outreach-drafts/publish",
- Handler: placement_topic.PublishPlacementTopicOutreachDraftHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/scan-jobs",
- Handler: placement_topic.StartPlacementTopicScanJobHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/scan-posts",
- Handler: placement_topic.ListPlacementTopicScanPostsHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/scan-posts/:postId",
- Handler: placement_topic.PatchPlacementTopicScanPostOutreachHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id/scan-posts/:postId",
- Handler: placement_topic.DeletePlacementTopicScanPostHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/scan-posts/batch-delete",
- Handler: placement_topic.BatchDeletePlacementTopicScanPostsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/scan-schedule",
- Handler: placement_topic.GetPlacementTopicScanScheduleHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:id/scan-schedule",
- Handler: placement_topic.UpsertPlacementTopicScanScheduleHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/placement/topics"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/:scope/:scope_id",
- Handler: setting.ListSettingsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:scope/:scope_id/:key",
- Handler: setting.GetSettingHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:scope/:scope_id/:key",
- Handler: setting.UpsertSettingHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:scope/:scope_id/:key",
- Handler: setting.DeleteSettingHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/settings"),
- )
-
- server.AddRoutes(
- rest.WithMiddlewares(
- []rest.Middleware{serverCtx.AuthJWT},
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/",
- Handler: threads_account.ListThreadsAccountsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/",
- Handler: threads_account.CreateThreadsAccountHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id",
- Handler: threads_account.GetThreadsAccountHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id",
- Handler: threads_account.UpdateThreadsAccountHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id",
- Handler: threads_account.DeleteThreadsAccountHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/activate",
- Handler: threads_account.ActivateThreadsAccountHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/ai-settings",
- Handler: threads_account.GetThreadsAccountAiSettingsHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:id/ai-settings",
- Handler: threads_account.UpdateThreadsAccountAiSettingsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/ai-settings/providers/:provider/models",
- Handler: threads_account.ListThreadsAccountAiProviderModelsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/api/disconnect",
- Handler: threads_account.DisconnectThreadsApiHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/api/playground",
- Handler: threads_account.RunThreadsApiPlaygroundHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/api/smoke-test",
- Handler: threads_account.RunThreadsApiSmokeTestHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/connection",
- Handler: threads_account.GetThreadsAccountConnectionHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/connection",
- Handler: threads_account.UpdateThreadsAccountConnectionHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/content-formulas",
- Handler: threads_account.ListContentFormulasHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/content-formulas/:formulaId",
- Handler: threads_account.GetContentFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/content-formulas/:formulaId",
- Handler: threads_account.PatchContentFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id/content-formulas/:formulaId",
- Handler: threads_account.DeleteContentFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-formulas/analyze",
- Handler: threads_account.AnalyzeContentFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-formulas/import-own-post",
- Handler: threads_account.ImportOwnPostToContentFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/content-formulas/search-posts",
- Handler: threads_account.SearchContentFormulaPostsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/diagnostics",
- Handler: threads_account.GetThreadsDiagnosticsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/mention-inbox",
- Handler: threads_account.ListMentionInboxHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/mention-inbox/:media_id/reply",
- Handler: threads_account.PublishMentionReplyHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/mention-inbox/sync",
- Handler: threads_account.SyncMentionInboxHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/own-post-formula",
- Handler: threads_account.GenerateOwnPostFormulaHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/own-post-formulas",
- Handler: threads_account.ListOwnPostFormulasHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/own-post-reply-draft",
- Handler: threads_account.GenerateOwnPostReplyDraftHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/post-performance",
- Handler: threads_account.ListThreadsPostPerformanceHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-alerts",
- Handler: threads_account.ListPublishAlertsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/publish-attachments",
- Handler: threads_account.UploadPublishAttachmentHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/publish-reply",
- Handler: threads_account.PublishThreadsReplyHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-calendar",
- Handler: threads_account.ListPublishQueueRangeHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-guard-policy",
- Handler: threads_account.GetPublishGuardPolicyHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:id/publish-guard-policy",
- Handler: threads_account.UpsertPublishGuardPolicyHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/publish-guard/resume",
- Handler: threads_account.ResumePublishGuardHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-health",
- Handler: threads_account.ListThreadsPublishHealthHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-inventory-policy",
- Handler: threads_account.GetPublishInventoryPolicyHandler(serverCtx),
- },
- {
- Method: http.MethodPut,
- Path: "/:id/publish-inventory-policy",
- Handler: threads_account.UpsertPublishInventoryPolicyHandler(serverCtx),
- },
- // refill-publish-inventory job removed (no step handler / template not seeded)
- {
- Method: http.MethodPost,
- Path: "/:id/publish-queue",
- Handler: threads_account.CreatePublishQueueItemHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-queue",
- Handler: threads_account.ListPublishQueueItemsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-queue/:qid",
- Handler: threads_account.GetPublishQueueItemHandler(serverCtx),
- },
- {
- Method: http.MethodPatch,
- Path: "/:id/publish-queue/:qid",
- Handler: threads_account.PatchPublishQueueItemHandler(serverCtx),
- },
- {
- Method: http.MethodDelete,
- Path: "/:id/publish-queue/:qid",
- Handler: threads_account.DeletePublishQueueItemHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/publish-queue/:qid/cancel",
- Handler: threads_account.CancelPublishQueueItemHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-queue/:qid/events",
- Handler: threads_account.ListPublishQueueEventsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/publish-queue/:qid/retry",
- Handler: threads_account.RetryPublishQueueItemHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/:id/publish-slot-insights",
- Handler: threads_account.GetPublishSlotInsightsHandler(serverCtx),
- },
- {
- Method: http.MethodPost,
- Path: "/:id/session/import",
- Handler: threads_account.ImportThreadsAccountSessionHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/oauth/config",
- Handler: threads_account.GetThreadsOauthConfigHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/oauth/logs",
- Handler: threads_account.ListThreadsOauthLogsHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/oauth/start",
- Handler: threads_account.StartThreadsOauthHandler(serverCtx),
- },
- {
- Method: http.MethodGet,
- Path: "/publish-dashboard-summary",
- Handler: threads_account.GetPublishDashboardSummaryHandler(serverCtx),
- },
- }...,
- ),
- rest.WithPrefix("/api/v1/threads-accounts"),
- )
-
- server.AddRoutes(
- []rest.Route{
- {
- Method: http.MethodGet,
- Path: "/oauth/callback",
- Handler: threads_account.ThreadsOauthCallbackHandler(serverCtx),
- },
- },
- rest.WithPrefix("/api/v1/threads-accounts"),
- )
-}
diff --git a/old/backend/internal/handler/setting/delete_setting_handler.go b/old/backend/internal/handler/setting/delete_setting_handler.go
deleted file mode 100644
index bc85b59..0000000
--- a/old/backend/internal/handler/setting/delete_setting_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package setting
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/setting"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteSettingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SettingKeyPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := setting.NewDeleteSettingLogic(r.Context(), svcCtx)
- err := l.DeleteSetting(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/setting/get_setting_handler.go b/old/backend/internal/handler/setting/get_setting_handler.go
deleted file mode 100644
index beaeeac..0000000
--- a/old/backend/internal/handler/setting/get_setting_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package setting
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/setting"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetSettingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SettingKeyPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := setting.NewGetSettingLogic(r.Context(), svcCtx)
- data, err := l.GetSetting(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/setting/list_settings_handler.go b/old/backend/internal/handler/setting/list_settings_handler.go
deleted file mode 100644
index 4c01f22..0000000
--- a/old/backend/internal/handler/setting/list_settings_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package setting
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/setting"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SettingPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := setting.NewListSettingsLogic(r.Context(), svcCtx)
- data, err := l.ListSettings(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/setting/upsert_setting_handler.go b/old/backend/internal/handler/setting/upsert_setting_handler.go
deleted file mode 100644
index c270de1..0000000
--- a/old/backend/internal/handler/setting/upsert_setting_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package setting
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/setting"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpsertSettingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SettingUpsertReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := setting.NewUpsertSettingLogic(r.Context(), svcCtx)
- data, err := l.UpsertSetting(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/static/extension_download.go b/old/backend/internal/handler/static/extension_download.go
deleted file mode 100644
index ad1d2f5..0000000
--- a/old/backend/internal/handler/static/extension_download.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package static
-
-import (
- "net/http"
- "os"
- "path/filepath"
-)
-
-func ExtensionDownloadHandler() http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- path, ok := resolveExtensionZipPath()
- if !ok {
- http.Error(w, "extension package not found", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", "application/zip")
- w.Header().Set("Content-Disposition", `attachment; filename="haixun-threads-sync.zip"`)
- http.ServeFile(w, r, path)
- }
-}
-
-func resolveExtensionZipPath() (string, bool) {
- candidates := []string{
- filepath.Join("web", "public", "downloads", "haixun-threads-sync.zip"),
- filepath.Join("backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
- filepath.Join("..", "web", "public", "downloads", "haixun-threads-sync.zip"),
- filepath.Join("extension", "haixun-threads-sync.zip"),
- }
- if wd, err := os.Getwd(); err == nil {
- candidates = append(candidates,
- filepath.Join(wd, "web", "public", "downloads", "haixun-threads-sync.zip"),
- filepath.Join(wd, "backend", "web", "public", "downloads", "haixun-threads-sync.zip"),
- filepath.Join(wd, "..", "extension", "haixun-threads-sync.zip"),
- )
- }
- for _, candidate := range candidates {
- if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > 0 {
- return candidate, true
- }
- }
- return "", false
-}
diff --git a/old/backend/internal/handler/static/public_asset.go b/old/backend/internal/handler/static/public_asset.go
deleted file mode 100644
index fd7625b..0000000
--- a/old/backend/internal/handler/static/public_asset.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package static
-
-import (
- "io"
- "net/http"
- "strings"
-
- "haixun-backend/internal/library/storage"
-)
-
-func PublicAssetHandler(reader storage.ObjectReader) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- if reader == nil {
- http.Error(w, "asset storage is not configured", http.StatusServiceUnavailable)
- return
- }
- key := strings.TrimSpace(r.URL.Query().Get("key"))
- if key == "" {
- http.Error(w, "missing asset key", http.StatusBadRequest)
- return
- }
- if !storage.IsPublicAssetKey(key) {
- http.Error(w, "asset not found", http.StatusNotFound)
- return
- }
-
- if strings.HasPrefix(key, storage.EphemeralKeyPrefix) {
- data, contentType, ok := storage.DefaultEphemeralAssetStore().Get(key)
- if !ok {
- http.Error(w, "asset not found", http.StatusNotFound)
- return
- }
- w.Header().Set("Content-Type", contentType)
- w.Header().Set("Cache-Control", "no-store")
- _, _ = w.Write(data)
- return
- }
-
- body, contentType, err := reader.GetObject(r.Context(), key)
- if err != nil {
- http.Error(w, "asset not found", http.StatusNotFound)
- return
- }
- defer body.Close()
-
- w.Header().Set("Content-Type", contentType)
- w.Header().Set("Cache-Control", "public, max-age=3600")
- _, _ = io.Copy(w, body)
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/handler/threads_account/activate_threads_account_handler.go b/old/backend/internal/handler/threads_account/activate_threads_account_handler.go
deleted file mode 100644
index b66ab88..0000000
--- a/old/backend/internal/handler/threads_account/activate_threads_account_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ActivateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewActivateThreadsAccountLogic(r.Context(), svcCtx)
- err := l.ActivateThreadsAccount(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/analyze_content_formula_handler.go b/old/backend/internal/handler/threads_account/analyze_content_formula_handler.go
deleted file mode 100644
index b9d9714..0000000
--- a/old/backend/internal/handler/threads_account/analyze_content_formula_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func AnalyzeContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.AnalyzeContentFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewAnalyzeContentFormulaLogic(r.Context(), svcCtx)
- data, err := l.AnalyzeContentFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go b/old/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go
deleted file mode 100644
index c521ec8..0000000
--- a/old/backend/internal/handler/threads_account/cancel_publish_queue_item_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func CancelPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishQueueItemPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewCancelPublishQueueItemLogic(r.Context(), svcCtx)
- data, err := l.CancelPublishQueueItem(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/create_publish_queue_item_handler.go b/old/backend/internal/handler/threads_account/create_publish_queue_item_handler.go
deleted file mode 100644
index 2f57380..0000000
--- a/old/backend/internal/handler/threads_account/create_publish_queue_item_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func CreatePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreatePublishQueueHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewCreatePublishQueueItemLogic(r.Context(), svcCtx)
- data, err := l.CreatePublishQueueItem(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/create_threads_account_handler.go b/old/backend/internal/handler/threads_account/create_threads_account_handler.go
deleted file mode 100644
index d744e98..0000000
--- a/old/backend/internal/handler/threads_account/create_threads_account_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func CreateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.CreateThreadsAccountReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewCreateThreadsAccountLogic(r.Context(), svcCtx)
- data, err := l.CreateThreadsAccount(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/delete_content_formula_handler.go b/old/backend/internal/handler/threads_account/delete_content_formula_handler.go
deleted file mode 100644
index e3ec1ee..0000000
--- a/old/backend/internal/handler/threads_account/delete_content_formula_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GetContentFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewDeleteContentFormulaLogic(r.Context(), svcCtx)
- data, err := l.DeleteContentFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go b/old/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go
deleted file mode 100644
index 568c3a6..0000000
--- a/old/backend/internal/handler/threads_account/delete_publish_queue_item_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func DeletePublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishQueueItemPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewDeletePublishQueueItemLogic(r.Context(), svcCtx)
- err := l.DeletePublishQueueItem(&req)
- response.Write(r.Context(), w, nil, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/delete_threads_account_handler.go b/old/backend/internal/handler/threads_account/delete_threads_account_handler.go
deleted file mode 100644
index 9029e7f..0000000
--- a/old/backend/internal/handler/threads_account/delete_threads_account_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DeleteThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewDeleteThreadsAccountLogic(r.Context(), svcCtx)
- data, err := l.DeleteThreadsAccount(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/disconnect_threads_api_handler.go b/old/backend/internal/handler/threads_account/disconnect_threads_api_handler.go
deleted file mode 100644
index a1b8f53..0000000
--- a/old/backend/internal/handler/threads_account/disconnect_threads_api_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func DisconnectThreadsApiHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewDisconnectThreadsApiLogic(r.Context(), svcCtx)
- data, err := l.DisconnectThreadsApi(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/generate_own_post_formula_handler.go b/old/backend/internal/handler/threads_account/generate_own_post_formula_handler.go
deleted file mode 100644
index f78d69d..0000000
--- a/old/backend/internal/handler/threads_account/generate_own_post_formula_handler.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GenerateOwnPostFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GenerateOwnPostFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewGenerateOwnPostFormulaLogic(r.Context(), svcCtx)
- data, err := l.GenerateOwnPostFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go b/old/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go
deleted file mode 100644
index fc8341c..0000000
--- a/old/backend/internal/handler/threads_account/generate_own_post_reply_draft_handler.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GenerateOwnPostReplyDraftHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GenerateOwnPostReplyDraftHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewGenerateOwnPostReplyDraftLogic(r.Context(), svcCtx)
- data, err := l.GenerateOwnPostReplyDraft(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_content_formula_handler.go b/old/backend/internal/handler/threads_account/get_content_formula_handler.go
deleted file mode 100644
index cc267a0..0000000
--- a/old/backend/internal/handler/threads_account/get_content_formula_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.GetContentFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewGetContentFormulaLogic(r.Context(), svcCtx)
- data, err := l.GetContentFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go b/old/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go
deleted file mode 100644
index cc261ed..0000000
--- a/old/backend/internal/handler/threads_account/get_publish_dashboard_summary_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func GetPublishDashboardSummaryHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := threads_account.NewGetPublishDashboardSummaryLogic(r.Context(), svcCtx)
- data, err := l.GetPublishDashboardSummary()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go b/old/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go
deleted file mode 100644
index 0f2171f..0000000
--- a/old/backend/internal/handler/threads_account/get_publish_guard_policy_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GetPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetPublishGuardPolicyLogic(r.Context(), svcCtx)
- data, err := l.GetPublishGuardPolicy(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go b/old/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go
deleted file mode 100644
index 6b0e387..0000000
--- a/old/backend/internal/handler/threads_account/get_publish_inventory_policy_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GetPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetPublishInventoryPolicyLogic(r.Context(), svcCtx)
- data, err := l.GetPublishInventoryPolicy(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_publish_queue_item_handler.go b/old/backend/internal/handler/threads_account/get_publish_queue_item_handler.go
deleted file mode 100644
index c525c26..0000000
--- a/old/backend/internal/handler/threads_account/get_publish_queue_item_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GetPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishQueueItemPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetPublishQueueItemLogic(r.Context(), svcCtx)
- data, err := l.GetPublishQueueItem(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go b/old/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go
deleted file mode 100644
index d1d21ad..0000000
--- a/old/backend/internal/handler/threads_account/get_publish_slot_insights_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GetPublishSlotInsightsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetPublishSlotInsightsLogic(r.Context(), svcCtx)
- data, err := l.GetPublishSlotInsights(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_threads_account_ai_settings_handler.go b/old/backend/internal/handler/threads_account/get_threads_account_ai_settings_handler.go
deleted file mode 100644
index 40b5640..0000000
--- a/old/backend/internal/handler/threads_account/get_threads_account_ai_settings_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
- data, err := l.GetThreadsAccountAiSettings(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_threads_account_connection_handler.go b/old/backend/internal/handler/threads_account/get_threads_account_connection_handler.go
deleted file mode 100644
index 8f2ee26..0000000
--- a/old/backend/internal/handler/threads_account/get_threads_account_connection_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetThreadsAccountConnectionLogic(r.Context(), svcCtx)
- data, err := l.GetThreadsAccountConnection(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_threads_account_handler.go b/old/backend/internal/handler/threads_account/get_threads_account_handler.go
deleted file mode 100644
index 23b4330..0000000
--- a/old/backend/internal/handler/threads_account/get_threads_account_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func GetThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetThreadsAccountLogic(r.Context(), svcCtx)
- data, err := l.GetThreadsAccount(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go b/old/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go
deleted file mode 100644
index 1053001..0000000
--- a/old/backend/internal/handler/threads_account/get_threads_diagnostics_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func GetThreadsDiagnosticsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewGetThreadsDiagnosticsLogic(r.Context(), svcCtx)
- data, err := l.GetThreadsDiagnostics(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go b/old/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go
deleted file mode 100644
index 74a2fde..0000000
--- a/old/backend/internal/handler/threads_account/get_threads_oauth_config_handler.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func GetThreadsOauthConfigHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := threads_account.NewGetThreadsOAuthConfigLogic(svcCtx)
- response.Write(r.Context(), w, l.GetThreadsOAuthConfig(), nil)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go b/old/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go
deleted file mode 100644
index 7848037..0000000
--- a/old/backend/internal/handler/threads_account/import_own_post_to_content_formula_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ImportOwnPostToContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ImportOwnPostFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewImportOwnPostToContentFormulaLogic(r.Context(), svcCtx)
- data, err := l.ImportOwnPostToContentFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/import_threads_account_session_handler.go b/old/backend/internal/handler/threads_account/import_threads_account_session_handler.go
deleted file mode 100644
index ec80178..0000000
--- a/old/backend/internal/handler/threads_account/import_threads_account_session_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-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.ImportThreadsAccountSessionHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewImportThreadsAccountSessionLogic(r.Context(), svcCtx)
- data, err := l.ImportThreadsAccountSession(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_content_formulas_handler.go b/old/backend/internal/handler/threads_account/list_content_formulas_handler.go
deleted file mode 100644
index 7b47455..0000000
--- a/old/backend/internal/handler/threads_account/list_content_formulas_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListContentFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListContentFormulasHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewListContentFormulasLogic(r.Context(), svcCtx)
- data, err := l.ListContentFormulas(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_mention_inbox_handler.go b/old/backend/internal/handler/threads_account/list_mention_inbox_handler.go
deleted file mode 100644
index 51139bf..0000000
--- a/old/backend/internal/handler/threads_account/list_mention_inbox_handler.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListMentionInboxHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewListMentionInboxLogic(r.Context(), svcCtx)
- data, err := l.ListMentionInbox(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_own_post_formulas_handler.go b/old/backend/internal/handler/threads_account/list_own_post_formulas_handler.go
deleted file mode 100644
index e40a02b..0000000
--- a/old/backend/internal/handler/threads_account/list_own_post_formulas_handler.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListOwnPostFormulasHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListOwnPostFormulasHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewListOwnPostFormulasLogic(r.Context(), svcCtx)
- data, err := l.ListOwnPostFormulas(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_publish_alerts_handler.go b/old/backend/internal/handler/threads_account/list_publish_alerts_handler.go
deleted file mode 100644
index 793552b..0000000
--- a/old/backend/internal/handler/threads_account/list_publish_alerts_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListPublishAlertsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListPublishAlertsLogic(r.Context(), svcCtx)
- data, err := l.ListPublishAlerts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_publish_queue_events_handler.go b/old/backend/internal/handler/threads_account/list_publish_queue_events_handler.go
deleted file mode 100644
index 7aed7d9..0000000
--- a/old/backend/internal/handler/threads_account/list_publish_queue_events_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListPublishQueueEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishQueueItemPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListPublishQueueEventsLogic(r.Context(), svcCtx)
- data, err := l.ListPublishQueueEvents(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_publish_queue_items_handler.go b/old/backend/internal/handler/threads_account/list_publish_queue_items_handler.go
deleted file mode 100644
index 8044880..0000000
--- a/old/backend/internal/handler/threads_account/list_publish_queue_items_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListPublishQueueItemsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListPublishQueueHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListPublishQueueItemsLogic(r.Context(), svcCtx)
- data, err := l.ListPublishQueueItems(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_publish_queue_range_handler.go b/old/backend/internal/handler/threads_account/list_publish_queue_range_handler.go
deleted file mode 100644
index 0c910b4..0000000
--- a/old/backend/internal/handler/threads_account/list_publish_queue_range_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListPublishQueueRangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishQueueRangeHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListPublishQueueRangeLogic(r.Context(), svcCtx)
- data, err := l.ListPublishQueueRange(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go b/old/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go
deleted file mode 100644
index e7ebf6a..0000000
--- a/old/backend/internal/handler/threads_account/list_threads_account_ai_provider_models_handler.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func ListThreadsAccountAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountAiProviderModelsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListThreadsAccountAiProviderModelsLogic(r.Context(), svcCtx)
- data, err := l.ListThreadsAccountAiProviderModels(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_threads_accounts_handler.go b/old/backend/internal/handler/threads_account/list_threads_accounts_handler.go
deleted file mode 100644
index ad16ced..0000000
--- a/old/backend/internal/handler/threads_account/list_threads_accounts_handler.go
+++ /dev/null
@@ -1,20 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListThreadsAccountsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := threads_account.NewListThreadsAccountsLogic(r.Context(), svcCtx)
- data, err := l.ListThreadsAccounts()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go b/old/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go
deleted file mode 100644
index 652dc36..0000000
--- a/old/backend/internal/handler/threads_account/list_threads_oauth_logs_handler.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
-)
-
-func ListThreadsOauthLogsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- l := threads_account.NewListThreadsOAuthLogsLogic(r.Context(), svcCtx)
- data, err := l.ListThreadsOAuthLogs()
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_threads_post_performance_handler.go b/old/backend/internal/handler/threads_account/list_threads_post_performance_handler.go
deleted file mode 100644
index 9771db6..0000000
--- a/old/backend/internal/handler/threads_account/list_threads_post_performance_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListThreadsPostPerformanceHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListThreadsPostPerformanceHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListThreadsPostPerformanceLogic(r.Context(), svcCtx)
- data, err := l.ListThreadsPostPerformance(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/list_threads_publish_health_handler.go b/old/backend/internal/handler/threads_account/list_threads_publish_health_handler.go
deleted file mode 100644
index 5b2c35b..0000000
--- a/old/backend/internal/handler/threads_account/list_threads_publish_health_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ListThreadsPublishHealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ListThreadsPublishHealthHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewListThreadsPublishHealthLogic(r.Context(), svcCtx)
- data, err := l.ListThreadsPublishHealth(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/patch_content_formula_handler.go b/old/backend/internal/handler/threads_account/patch_content_formula_handler.go
deleted file mode 100644
index 56a98d3..0000000
--- a/old/backend/internal/handler/threads_account/patch_content_formula_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func PatchContentFormulaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PatchContentFormulaHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewPatchContentFormulaLogic(r.Context(), svcCtx)
- data, err := l.PatchContentFormula(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go b/old/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go
deleted file mode 100644
index 13276a4..0000000
--- a/old/backend/internal/handler/threads_account/patch_publish_queue_item_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func PatchPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PatchPublishQueueHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewPatchPublishQueueItemLogic(r.Context(), svcCtx)
- data, err := l.PatchPublishQueueItem(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/publish_mention_reply_handler.go b/old/backend/internal/handler/threads_account/publish_mention_reply_handler.go
deleted file mode 100644
index 3c76856..0000000
--- a/old/backend/internal/handler/threads_account/publish_mention_reply_handler.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package threads_account
-
-import (
- "io"
- "net/http"
- "strings"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func PublishMentionReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishMentionReplyHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- r.Body = http.MaxBytesReader(w, r.Body, maxPublishAttachmentBytes+1<<20)
- if strings.TrimSpace(req.Text) == "" {
- req.Text = strings.TrimSpace(r.FormValue("text"))
- }
-
- imageFile, err := readOptionalReplyImage(r)
- if err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if imageFile != nil && imageFile.Body != nil {
- if closer, ok := imageFile.Body.(io.Closer); ok {
- defer closer.Close()
- }
- }
-
- l := threads_account.NewPublishMentionReplyLogic(r.Context(), svcCtx)
- data, err := l.PublishMentionReply(&req, imageFile)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/publish_threads_reply_handler.go b/old/backend/internal/handler/threads_account/publish_threads_reply_handler.go
deleted file mode 100644
index 52b1e12..0000000
--- a/old/backend/internal/handler/threads_account/publish_threads_reply_handler.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package threads_account
-
-import (
- "errors"
- "net/http"
- "strings"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func PublishThreadsReplyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishThreadsReplyHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- r.Body = http.MaxBytesReader(w, r.Body, maxPublishAttachmentBytes+1<<20)
- if strings.TrimSpace(req.ReplyToID) == "" {
- req.ReplyToID = strings.TrimSpace(r.FormValue("reply_to_id"))
- }
- if strings.TrimSpace(req.Text) == "" {
- req.Text = strings.TrimSpace(r.FormValue("text"))
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- var imageFile *threads_account.ReplyImageFile
- file, header, err := r.FormFile("image")
- if err == nil {
- defer file.Close()
- imageFile = &threads_account.ReplyImageFile{
- Filename: header.Filename,
- ContentType: header.Header.Get("Content-Type"),
- Body: file,
- }
- } else if !errors.Is(err, http.ErrMissingFile) {
- if errors.As(err, new(*http.MaxBytesError)) {
- response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputInvalidFormat("request body too large"))
- return
- }
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewPublishThreadsReplyLogic(r.Context(), svcCtx)
- data, err := l.PublishThreadsReply(&req, imageFile)
- response.Write(r.Context(), w, data, err)
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/handler/threads_account/reply_multipart.go b/old/backend/internal/handler/threads_account/reply_multipart.go
deleted file mode 100644
index b65026f..0000000
--- a/old/backend/internal/handler/threads_account/reply_multipart.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threads_account
-
-import (
- "errors"
- "net/http"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/logic/threads_account"
-)
-
-func readOptionalReplyImage(r *http.Request) (*threads_account.ReplyImageFile, error) {
- file, header, err := r.FormFile("image")
- if errors.Is(err, http.ErrMissingFile) {
- return nil, nil
- }
- if err != nil {
- if errors.As(err, new(*http.MaxBytesError)) {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat("request body too large")
- }
- return nil, err
- }
- return &threads_account.ReplyImageFile{
- Filename: header.Filename,
- ContentType: header.Header.Get("Content-Type"),
- Body: file,
- }, nil
-}
\ No newline at end of file
diff --git a/old/backend/internal/handler/threads_account/resume_publish_guard_handler.go b/old/backend/internal/handler/threads_account/resume_publish_guard_handler.go
deleted file mode 100644
index 67e2dd3..0000000
--- a/old/backend/internal/handler/threads_account/resume_publish_guard_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func ResumePublishGuardHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewResumePublishGuardLogic(r.Context(), svcCtx)
- data, err := l.ResumePublishGuard(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go b/old/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go
deleted file mode 100644
index c56893c..0000000
--- a/old/backend/internal/handler/threads_account/retry_publish_queue_item_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func RetryPublishQueueItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.PublishQueueItemPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewRetryPublishQueueItemLogic(r.Context(), svcCtx)
- data, err := l.RetryPublishQueueItem(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/run_threads_api_playground_handler.go b/old/backend/internal/handler/threads_account/run_threads_api_playground_handler.go
deleted file mode 100644
index b0be4e7..0000000
--- a/old/backend/internal/handler/threads_account/run_threads_api_playground_handler.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func RunThreadsApiPlaygroundHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.RunThreadsAPIPlaygroundHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewRunThreadsAPIPlaygroundLogic(r.Context(), svcCtx)
- data, err := l.RunThreadsAPIPlayground(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go b/old/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go
deleted file mode 100644
index 3893098..0000000
--- a/old/backend/internal/handler/threads_account/run_threads_api_smoke_test_handler.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func RunThreadsApiSmokeTestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewRunThreadsAPISmokeTestLogic(r.Context(), svcCtx)
- data, err := l.RunThreadsAPISmokeTest(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/search_content_formula_posts_handler.go b/old/backend/internal/handler/threads_account/search_content_formula_posts_handler.go
deleted file mode 100644
index 75139b6..0000000
--- a/old/backend/internal/handler/threads_account/search_content_formula_posts_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func SearchContentFormulaPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SearchContentFormulaPostsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewSearchContentFormulaPostsLogic(r.Context(), svcCtx)
- data, err := l.SearchContentFormulaPosts(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/start_threads_oauth_handler.go b/old/backend/internal/handler/threads_account/start_threads_oauth_handler.go
deleted file mode 100644
index 9c2c39a..0000000
--- a/old/backend/internal/handler/threads_account/start_threads_oauth_handler.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func StartThreadsOauthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.StartThreadsOAuthQuery
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewStartThreadsOAuthLogic(r.Context(), svcCtx)
- data, err := l.StartThreadsOAuth(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/sync_mention_inbox_handler.go b/old/backend/internal/handler/threads_account/sync_mention_inbox_handler.go
deleted file mode 100644
index 2978069..0000000
--- a/old/backend/internal/handler/threads_account/sync_mention_inbox_handler.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func SyncMentionInboxHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.SyncMentionInboxHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- l := threads_account.NewSyncMentionInboxLogic(r.Context(), svcCtx)
- data, err := l.SyncMentionInbox(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/threads_oauth_callback_handler.go b/old/backend/internal/handler/threads_account/threads_oauth_callback_handler.go
deleted file mode 100644
index d9d7d6d..0000000
--- a/old/backend/internal/handler/threads_account/threads_oauth_callback_handler.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/svc"
- "haixun-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.ThreadsOAuthCallbackQuery
- if err := httpx.Parse(r, &req); err != nil {
- http.Redirect(w, r, threads_account.OAuthFailureRedirect(svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), http.StatusFound)
- return
- }
-
- l := threads_account.NewThreadsOAuthCallbackLogic(r.Context(), svcCtx)
- redirectURL, err := l.ThreadsOAuthCallback(&req)
- if err != nil {
- http.Redirect(w, r, threads_account.OAuthFailureRedirect(svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), http.StatusFound)
- return
- }
- http.Redirect(w, r, redirectURL, http.StatusFound)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/update_threads_account_ai_settings_handler.go b/old/backend/internal/handler/threads_account/update_threads_account_ai_settings_handler.go
deleted file mode 100644
index d775315..0000000
--- a/old/backend/internal/handler/threads_account/update_threads_account_ai_settings_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateThreadsAccountAiSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateThreadsAccountAiSettingsHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewUpdateThreadsAccountAiSettingsLogic(r.Context(), svcCtx)
- data, err := l.UpdateThreadsAccountAiSettings(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/update_threads_account_connection_handler.go b/old/backend/internal/handler/threads_account/update_threads_account_connection_handler.go
deleted file mode 100644
index d5d5e39..0000000
--- a/old/backend/internal/handler/threads_account/update_threads_account_connection_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateThreadsAccountConnectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateThreadsAccountConnectionHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewUpdateThreadsAccountConnectionLogic(r.Context(), svcCtx)
- data, err := l.UpdateThreadsAccountConnection(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/update_threads_account_handler.go b/old/backend/internal/handler/threads_account/update_threads_account_handler.go
deleted file mode 100644
index c7432f0..0000000
--- a/old/backend/internal/handler/threads_account/update_threads_account_handler.go
+++ /dev/null
@@ -1,33 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func UpdateThreadsAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpdateThreadsAccountHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewUpdateThreadsAccountLogic(r.Context(), svcCtx)
- data, err := l.UpdateThreadsAccount(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/upload_publish_attachment_handler.go b/old/backend/internal/handler/threads_account/upload_publish_attachment_handler.go
deleted file mode 100644
index 29d3ef8..0000000
--- a/old/backend/internal/handler/threads_account/upload_publish_attachment_handler.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package threads_account
-
-import (
- "errors"
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-const maxPublishAttachmentBytes = 8 << 20
-
-func UploadPublishAttachmentHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.ThreadsAccountPath
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- r.Body = http.MaxBytesReader(w, r.Body, maxPublishAttachmentBytes)
- file, header, err := r.FormFile("image")
- if err != nil {
- if errors.As(err, new(*http.MaxBytesError)) {
- response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputInvalidFormat("image must be smaller than 8MB"))
- return
- }
- response.Write(r.Context(), w, nil, app.For(code.ThreadsAccount).InputMissingRequired("image file is required"))
- return
- }
- defer file.Close()
- l := threads_account.NewUploadPublishAttachmentLogic(r.Context(), svcCtx)
- data, err := l.UploadPublishAttachment(req.ID, header.Filename, header.Header.Get("Content-Type"), file)
- response.Write(r.Context(), w, data, err)
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go b/old/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go
deleted file mode 100644
index 95eeb81..0000000
--- a/old/backend/internal/handler/threads_account/upsert_publish_guard_policy_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpsertPublishGuardPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpsertPublishGuardPolicyHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewUpsertPublishGuardPolicyLogic(r.Context(), svcCtx)
- data, err := l.UpsertPublishGuardPolicy(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go b/old/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go
deleted file mode 100644
index 9e55ad4..0000000
--- a/old/backend/internal/handler/threads_account/upsert_publish_inventory_policy_handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "net/http"
-
- "github.com/zeromicro/go-zero/rest/httpx"
- "haixun-backend/internal/logic/threads_account"
- "haixun-backend/internal/response"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-func UpsertPublishInventoryPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- var req types.UpsertPublishInventoryPolicyHandlerReq
- if err := httpx.Parse(r, &req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
- if err := svcCtx.Validator.ValidateAll(&req); err != nil {
- response.Write(r.Context(), w, nil, response.WrapRequestError(err))
- return
- }
-
- l := threads_account.NewUpsertPublishInventoryPolicyLogic(r.Context(), svcCtx)
- data, err := l.UpsertPublishInventoryPolicy(&req)
- response.Write(r.Context(), w, data, err)
- }
-}
diff --git a/old/backend/internal/library/authctx/context.go b/old/backend/internal/library/authctx/context.go
deleted file mode 100644
index 99252e7..0000000
--- a/old/backend/internal/library/authctx/context.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package authctx
-
-import "context"
-
-type Actor struct {
- TenantID string
- UID string
- JTI string
-}
-
-type actorKey struct{}
-
-func WithActor(ctx context.Context, actor Actor) context.Context {
- return context.WithValue(ctx, actorKey{}, actor)
-}
-
-func ActorFromContext(ctx context.Context) (Actor, bool) {
- actor, ok := ctx.Value(actorKey{}).(Actor)
- return actor, ok
-}
diff --git a/old/backend/internal/library/brave/breaker.go b/old/backend/internal/library/brave/breaker.go
deleted file mode 100644
index c42bb42..0000000
--- a/old/backend/internal/library/brave/breaker.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package brave
-
-import (
- "sync"
- "time"
-)
-
-const (
- breakerFailureThreshold = 5
- breakerCooldown = 2 * time.Minute
-)
-
-type breakerState struct {
- mu sync.Mutex
- failures int
- openUntil time.Time
-}
-
-var globalBreaker breakerState
-
-// BreakerOpen reports whether Brave calls should be skipped due to recent failures.
-func BreakerOpen() bool {
- globalBreaker.mu.Lock()
- defer globalBreaker.mu.Unlock()
- return time.Now().Before(globalBreaker.openUntil)
-}
-
-func recordBreakerSuccess() {
- globalBreaker.mu.Lock()
- defer globalBreaker.mu.Unlock()
- globalBreaker.failures = 0
- globalBreaker.openUntil = time.Time{}
-}
-
-func recordBreakerFailure() {
- globalBreaker.mu.Lock()
- defer globalBreaker.mu.Unlock()
- globalBreaker.failures++
- if globalBreaker.failures >= breakerFailureThreshold {
- globalBreaker.openUntil = time.Now().Add(breakerCooldown)
- globalBreaker.failures = 0
- }
-}
-
-// ResetBreakerForTest clears breaker state.
-func ResetBreakerForTest() {
- globalBreaker.mu.Lock()
- defer globalBreaker.mu.Unlock()
- globalBreaker.failures = 0
- globalBreaker.openUntil = time.Time{}
-}
diff --git a/old/backend/internal/library/brave/client.go b/old/backend/internal/library/brave/client.go
deleted file mode 100644
index a642061..0000000
--- a/old/backend/internal/library/brave/client.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package brave
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-const defaultBaseURL = "https://api.search.brave.com/res/v1/web/search"
-
-type Mode string
-
-const (
- ModeKnowledgeExpand Mode = "knowledge_expand"
- ModeThreadsDiscover Mode = "threads_discover"
-)
-
-type SearchResult struct {
- Title string `json:"title"`
- Snippet string `json:"snippet"`
- URL string `json:"url"`
-}
-
-type SearchResponse struct {
- Results []SearchResult
- Query string
- Status string // success | unavailable
-}
-
-type Client struct {
- apiKey string
- baseURL string
- http *http.Client
-}
-
-func NewClient(apiKey string) *Client {
- return &Client{
- apiKey: strings.TrimSpace(apiKey),
- baseURL: defaultBaseURL,
- http: &http.Client{
- Timeout: 20 * time.Second,
- },
- }
-}
-
-func (c *Client) Enabled() bool {
- return c != nil && c.apiKey != ""
-}
-
-type SearchOptions struct {
- Query string
- Limit int
- Mode Mode
- Country string
- SearchLang string
- // StartPublishedDate is accepted for forward-compatibility (Brave's
- // `freshness` query parameter maps from this once wired). Current brave
- // client keeps it as a passthrough hint and does not yet append it to the
- // request, so callers that depend on date filtering should pair it with
- // Exa (which honours startPublishedDate server-side).
- StartPublishedDate string
-}
-
-func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
- out := SearchResponse{Query: strings.TrimSpace(opts.Query), Status: "unavailable"}
- if !c.Enabled() {
- return out, nil
- }
- if BreakerOpen() {
- return out, fmt.Errorf("brave search temporarily paused after repeated failures")
- }
- if out.Query == "" {
- return out, fmt.Errorf("brave search query is required")
- }
-
- limit := opts.Limit
- if limit <= 0 {
- limit = 5
- }
- if limit > 20 {
- limit = 20
- }
-
- country := strings.TrimSpace(opts.Country)
- if country == "" {
- country = "tw"
- }
- searchLang := strings.TrimSpace(opts.SearchLang)
- if searchLang == "" {
- searchLang = "zh-hant"
- }
-
- u, err := url.Parse(c.baseURL)
- if err != nil {
- return out, err
- }
- q := u.Query()
- q.Set("q", out.Query)
- q.Set("count", fmt.Sprintf("%d", limit))
- q.Set("country", country)
- q.Set("search_lang", searchLang)
- u.RawQuery = q.Encode()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
- if err != nil {
- return out, err
- }
- req.Header.Set("Accept", "application/json")
- req.Header.Set("X-Subscription-Token", c.apiKey)
-
- res, err := c.http.Do(req)
- if err != nil {
- recordBreakerFailure()
- return out, nil
- }
- defer res.Body.Close()
-
- if res.StatusCode != http.StatusOK {
- if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 {
- recordBreakerFailure()
- }
- return out, nil
- }
-
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return out, nil
- }
-
- var payload struct {
- Web struct {
- Results []struct {
- Title string `json:"title"`
- Description string `json:"description"`
- URL string `json:"url"`
- } `json:"results"`
- } `json:"web"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return out, nil
- }
-
- threadsOnly := opts.Mode == ModeThreadsDiscover
- for _, item := range payload.Web.Results {
- rawURL := strings.TrimSpace(item.URL)
- if rawURL == "" {
- continue
- }
- if threadsOnly && !isThreadsURL(rawURL) {
- continue
- }
- out.Results = append(out.Results, SearchResult{
- Title: strings.TrimSpace(item.Title),
- Snippet: strings.TrimSpace(item.Description),
- URL: rawURL,
- })
- if len(out.Results) >= limit {
- break
- }
- }
- out.Status = "success"
- recordBreakerSuccess()
- return out, nil
-}
-
-func isThreadsURL(raw string) bool {
- lower := strings.ToLower(raw)
- return strings.Contains(lower, "threads.com") || strings.Contains(lower, "threads.net")
-}
diff --git a/old/backend/internal/library/clock/clock.go b/old/backend/internal/library/clock/clock.go
deleted file mode 100644
index 7740cd8..0000000
--- a/old/backend/internal/library/clock/clock.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package clock
-
-import "time"
-
-// StorageTimezone is the only timezone used for persisted timestamps.
-const StorageTimezone = "UTC"
-
-// Now returns the current instant in UTC.
-func Now() time.Time {
- return time.Now().UTC()
-}
-
-// NowUnixNano returns the current instant as UTC unix nanoseconds.
-func NowUnixNano() int64 {
- return Now().UnixNano()
-}
-
-// UnixNano converts an instant to UTC unix nanoseconds.
-func UnixNano(t time.Time) int64 {
- return t.UTC().UnixNano()
-}
-
-// FromUnixNano parses UTC unix nanoseconds.
-func FromUnixNano(nano int64) time.Time {
- return time.Unix(0, nano).UTC()
-}
-
-// AddSecondsFromNow returns UTC unix nanoseconds after the given seconds.
-func AddSecondsFromNow(seconds int) int64 {
- return Now().Add(time.Duration(seconds) * time.Second).UnixNano()
-}
-
-// SecondsToNanos converts whole seconds to nanoseconds.
-func SecondsToNanos(seconds int) int64 {
- return int64(seconds) * int64(time.Second)
-}
diff --git a/old/backend/internal/library/clock/clock_test.go b/old/backend/internal/library/clock/clock_test.go
deleted file mode 100644
index 369fe90..0000000
--- a/old/backend/internal/library/clock/clock_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package clock
-
-import (
- "testing"
- "time"
-)
-
-func TestNow_IsUTC(t *testing.T) {
- now := Now()
- if now.Location() != time.UTC {
- t.Fatalf("location = %v, want UTC", now.Location())
- }
-}
-
-func TestFromUnixNano_RoundTrip(t *testing.T) {
- nano := NowUnixNano()
- parsed := FromUnixNano(nano)
- if parsed.UnixNano() != nano {
- t.Fatalf("parsed = %d, want %d", parsed.UnixNano(), nano)
- }
- if parsed.Location() != time.UTC {
- t.Fatalf("location = %v, want UTC", parsed.Location())
- }
-}
diff --git a/old/backend/internal/library/copymission/matrix_lock.go b/old/backend/internal/library/copymission/matrix_lock.go
deleted file mode 100644
index cb79cb9..0000000
--- a/old/backend/internal/library/copymission/matrix_lock.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package copymission
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-const matrixReplaceLockTTL = 10 * time.Minute
-
-// WithMatrixReplaceLock serializes matrix draft replacement for one copy mission.
-func WithMatrixReplaceLock(
- ctx context.Context,
- redis *goredis.Client,
- tenantID, missionID, holder string,
- fn func() error,
-) error {
- if fn == nil {
- return nil
- }
- tenantID = strings.TrimSpace(tenantID)
- missionID = strings.TrimSpace(missionID)
- holder = strings.TrimSpace(holder)
- if tenantID == "" || missionID == "" {
- return fn()
- }
- if redis == nil {
- return fn()
- }
- key := fmt.Sprintf("haixun:copy_matrix:%s:%s", tenantID, missionID)
- if holder == "" {
- holder = "matrix-replace"
- }
- ok, err := redis.SetNX(ctx, key, holder, matrixReplaceLockTTL).Result()
- if err != nil {
- return err
- }
- if !ok {
- return errors.New("copy matrix replacement already in progress")
- }
- defer func() {
- script := `
-if redis.call("get", KEYS[1]) == ARGV[1] then
- return redis.call("del", KEYS[1])
-end
-return 0
-`
- _ = redis.Eval(ctx, script, []string{key}, holder).Err()
- }()
- return fn()
-}
diff --git a/old/backend/internal/library/copyvoice/human.go b/old/backend/internal/library/copyvoice/human.go
deleted file mode 100644
index ac3352b..0000000
--- a/old/backend/internal/library/copyvoice/human.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package copyvoice
-
-import "strings"
-
-// SystemRules are shared instructions for human-like Threads copy generation.
-func SystemRules() string {
- return strings.TrimSpace(`寫作目標:像真人在 Threads / Instagram 發文,不是在套模板。若有人設語言指紋,必須優先照它的說話習慣寫。
-
-敘事(比結構重要):
-- 依主題選內容型態:心情陪伴、知識整理、清單工具、互動提問、輕幽默共鳴等,不要每篇都同一套。
-- 可以寫具體場景,也可以先承接情緒或整理資訊;不要為了故事感硬編不存在的生活細節。
-- 開場、推進方式、收尾都要自然變化;禁止固定三段式、固定問句收尾、固定金句收束。
-
-仿寫原則:
-- 只學參考文的情緒力度、資訊密度與節奏,不學填空骨架或固定 hook 句型。
-- 禁止照抄參考句型,只換幾個名詞;要用人設語言重新說一次。
-- 篇幅、行數、標點、換行優先貼近人設語言指紋;沒有指紋時再參考原文篇幅。
-- 每一句都要推進情緒、資訊或理解;重複強調、無關鋪陳、廉價雞湯一律刪掉。
-
-禁止 AI 腔與複製感:
-- 不要:過度總結、硬寫強 hook、每篇都同一種 CTA、教科書定義、品牌口吻、客服腔。
-- 不要:為了像人設而硬塞人設標籤或興趣名詞。
-- 不要全域禁止某個口頭禪;若人設樣本常用「其實」「有時候」「後來」等自然轉折,可以使用,但不能每篇硬塞。`)
-}
-
-// PersonaCalibrationNote reminds the model how to use persona blocks.
-func PersonaCalibrationNote() string {
- return "注意:人設定位決定誰在說,語言指紋決定怎麼說;請遵守段落節奏、標點換行、知識轉譯與 CTA 習慣,不要套固定模板。"
-}
-
-// NarrativeLens picks a storytelling angle from a stable seed (e.g. scan post id).
-func NarrativeLens(seed string) string {
- lenses := []string{
- "從一個具體場景開始講,再帶出感受",
- "先坦白一件小事或窘況,再自然轉到觀點",
- "像跟朋友聊天一樣開場,語氣可以鬆一點",
- "從某個瞬間的畫面切入,再講後來怎麼想",
- }
- if strings.TrimSpace(seed) == "" {
- return lenses[0]
- }
- sum := 0
- for _, ch := range seed {
- sum = sum*31 + int(ch)
- }
- if sum < 0 {
- sum = -sum
- }
- return lenses[sum%len(lenses)]
-}
diff --git a/old/backend/internal/library/copyvoice/human_test.go b/old/backend/internal/library/copyvoice/human_test.go
deleted file mode 100644
index ee9a620..0000000
--- a/old/backend/internal/library/copyvoice/human_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package copyvoice
-
-import "testing"
-
-func TestNarrativeLensStableForSeed(t *testing.T) {
- a := NarrativeLens("scan-post-1")
- b := NarrativeLens("scan-post-1")
- if a == "" || a != b {
- t.Fatalf("NarrativeLens() = %q, %q", a, b)
- }
-}
-
-func TestNarrativeLensDiffersAcrossSeeds(t *testing.T) {
- seen := map[string]struct{}{}
- for _, seed := range []string{"a", "b", "c", "d", "e", "f"} {
- seen[NarrativeLens(seed)] = struct{}{}
- }
- if len(seen) < 2 {
- t.Fatalf("expected varied lenses, got %d", len(seen))
- }
-}
diff --git a/old/backend/internal/library/crypto/aesgcm.go b/old/backend/internal/library/crypto/aesgcm.go
deleted file mode 100644
index d802417..0000000
--- a/old/backend/internal/library/crypto/aesgcm.go
+++ /dev/null
@@ -1,99 +0,0 @@
-// Package crypto provides application-layer encryption for sensitive data at
-// rest (browser session storage, third-party API keys).
-//
-// Ciphertext format: "enc:v1:" + base64url(nonce || ciphertext||tag).
-// Values without the prefix are treated as legacy plaintext and returned as-is
-// on Decrypt, so enabling encryption is backward compatible with existing data.
-package crypto
-
-import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "encoding/base64"
- "errors"
- "fmt"
- "strings"
-)
-
-const cipherPrefix = "enc:v1:"
-
-// Cipher encrypts/decrypts secret strings. When built without a key it is
-// disabled and acts as a passthrough (for local dev), but Decrypt still
-// transparently handles previously written ciphertext if any.
-type Cipher struct {
- aead cipher.AEAD
-}
-
-// New builds a Cipher from a base64-encoded 32-byte (AES-256) key.
-// An empty key returns a disabled (passthrough) cipher.
-func New(base64Key string) (*Cipher, error) {
- base64Key = strings.TrimSpace(base64Key)
- if base64Key == "" {
- return &Cipher{}, nil
- }
- key, err := base64.StdEncoding.DecodeString(base64Key)
- if err != nil {
- return nil, fmt.Errorf("crypto: invalid base64 encryption key: %w", err)
- }
- if len(key) != 32 {
- return nil, fmt.Errorf("crypto: encryption key must be 32 bytes (got %d)", len(key))
- }
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("crypto: new cipher: %w", err)
- }
- aead, err := cipher.NewGCM(block)
- if err != nil {
- return nil, fmt.Errorf("crypto: new gcm: %w", err)
- }
- return &Cipher{aead: aead}, nil
-}
-
-// Enabled reports whether a key is configured.
-func (c *Cipher) Enabled() bool {
- return c != nil && c.aead != nil
-}
-
-// Encrypt returns ciphertext for non-empty plaintext when enabled; otherwise
-// it returns the input unchanged.
-func (c *Cipher) Encrypt(plaintext string) (string, error) {
- if !c.Enabled() || plaintext == "" {
- return plaintext, nil
- }
- if strings.HasPrefix(plaintext, cipherPrefix) {
- // Already encrypted; avoid double-encrypting.
- return plaintext, nil
- }
- nonce := make([]byte, c.aead.NonceSize())
- if _, err := rand.Read(nonce); err != nil {
- return "", fmt.Errorf("crypto: read nonce: %w", err)
- }
- sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil)
- return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
-}
-
-// Decrypt reverses Encrypt. Values without the cipher prefix are returned
-// unchanged (legacy plaintext), keeping the change backward compatible.
-func (c *Cipher) Decrypt(value string) (string, error) {
- if !strings.HasPrefix(value, cipherPrefix) {
- return value, nil
- }
- if !c.Enabled() {
- return "", errors.New("crypto: encrypted value found but no encryption key configured")
- }
- raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, cipherPrefix))
- if err != nil {
- return "", fmt.Errorf("crypto: decode ciphertext: %w", err)
- }
- nonceSize := c.aead.NonceSize()
- if len(raw) < nonceSize {
- return "", errors.New("crypto: ciphertext too short")
- }
- nonce, ct := raw[:nonceSize], raw[nonceSize:]
- plain, err := c.aead.Open(nil, nonce, ct, nil)
- if err != nil {
- return "", fmt.Errorf("crypto: decrypt: %w", err)
- }
- return string(plain), nil
-}
diff --git a/old/backend/internal/library/errors/code/types.go b/old/backend/internal/library/errors/code/types.go
deleted file mode 100644
index 97ffa9f..0000000
--- a/old/backend/internal/library/errors/code/types.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package code
-
-type Scope uint32
-type Category uint32
-type Detail uint32
-
-const (
- Unset Scope = 0
- Facade Scope = 10
- Setting Scope = 32
- AI Scope = 33
- Job Scope = 34
- Auth Scope = 35
- Member Scope = 36
- Permission Scope = 37
- ThreadsAccount Scope = 38
- Persona Scope = 39
- Brand Scope = 40
- CategoryMultiplier = 1000
- ScopeMultiplier = 1000000
- DefaultDetail Detail = 0
-)
-
-const (
- DefaultSuccessFullCodeInt int64 = 102000
- DefaultSuccessMessage string = "SUCCESS"
-)
-
-const (
- InputInvalidFormat Category = 101
- InputMissingRequired Category = 104
- DBError Category = 201
- DBUnavailable Category = 204
- ResNotFound Category = 301
- ResConflict Category = 303
- ResInvalidState Category = 309
- AuthUnauthorized Category = 401
- AuthForbidden Category = 505
- SysInternal Category = 601
- SysNotImplemented Category = 605
- SvcThirdParty Category = 802
-)
diff --git a/old/backend/internal/library/errors/errors.go b/old/backend/internal/library/errors/errors.go
deleted file mode 100644
index fa1727b..0000000
--- a/old/backend/internal/library/errors/errors.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package errors
-
-import (
- "errors"
- "fmt"
- "net/http"
-
- "haixun-backend/internal/library/errors/code"
-)
-
-type Error struct {
- scope code.Scope
- category code.Category
- detail code.Detail
- message string
- cause error
-}
-
-func New(scope code.Scope, category code.Category, detail code.Detail, message string) *Error {
- return &Error{scope: scope, category: category, detail: detail, message: message}
-}
-
-func FromError(err error) *Error {
- var e *Error
- if errors.As(err, &e) {
- return e
- }
- return nil
-}
-
-func (e *Error) Error() string {
- if e == nil {
- return ""
- }
- return e.message
-}
-
-func (e *Error) Unwrap() error {
- if e == nil {
- return nil
- }
- return e.cause
-}
-
-func (e *Error) WithCause(cause error) *Error {
- if e == nil {
- return nil
- }
- cp := *e
- cp.cause = cause
- return &cp
-}
-
-func (e *Error) Scope() code.Scope {
- if e == nil {
- return code.Unset
- }
- return e.scope
-}
-
-func (e *Error) Category() code.Category {
- if e == nil {
- return code.SysInternal
- }
- return e.category
-}
-
-func (e *Error) Detail() code.Detail {
- if e == nil {
- return code.DefaultDetail
- }
- return e.detail
-}
-
-func (e *Error) Code() uint32 {
- if e == nil {
- return 0
- }
- return uint32(e.scope)*code.ScopeMultiplier + uint32(e.category)*code.CategoryMultiplier + uint32(e.detail)
-}
-
-func (e *Error) DisplayCode() string {
- if e == nil {
- return "00000000"
- }
- return fmt.Sprintf("%08d", e.Code())
-}
-
-func (e *Error) HTTPStatus() int {
- if e == nil {
- return http.StatusInternalServerError
- }
- switch e.category {
- case code.InputInvalidFormat, code.InputMissingRequired:
- return http.StatusBadRequest
- case code.DBUnavailable:
- return http.StatusServiceUnavailable
- case code.ResNotFound:
- return http.StatusNotFound
- case code.ResConflict:
- return http.StatusConflict
- case code.ResInvalidState:
- return http.StatusConflict
- case code.AuthUnauthorized:
- return http.StatusUnauthorized
- case code.AuthForbidden:
- return http.StatusForbidden
- case code.SvcThirdParty:
- return http.StatusBadGateway
- default:
- return http.StatusInternalServerError
- }
-}
-
-type Builder struct {
- scope code.Scope
-}
-
-func For(scope code.Scope) Builder {
- return Builder{scope: scope}
-}
-
-func (b Builder) InputInvalidFormat(message string) *Error {
- return New(b.scope, code.InputInvalidFormat, 0, message)
-}
-
-func (b Builder) InputMissingRequired(message string) *Error {
- return New(b.scope, code.InputMissingRequired, 0, message)
-}
-
-func (b Builder) DBError(message string) *Error {
- return New(b.scope, code.DBError, 0, message)
-}
-
-func (b Builder) DBUnavailable(message string) *Error {
- return New(b.scope, code.DBUnavailable, 0, message)
-}
-
-func (b Builder) ResNotFound(message string) *Error {
- return New(b.scope, code.ResNotFound, 0, message)
-}
-
-func (b Builder) ResConflict(message string) *Error {
- return New(b.scope, code.ResConflict, 0, message)
-}
-
-func (b Builder) ResInvalidState(message string) *Error {
- return New(b.scope, code.ResInvalidState, 0, message)
-}
-
-func (b Builder) AuthUnauthorized(message string) *Error {
- return New(b.scope, code.AuthUnauthorized, 0, message)
-}
-
-func (b Builder) AuthForbidden(message string) *Error {
- return New(b.scope, code.AuthForbidden, 0, message)
-}
-
-func (b Builder) SysInternal(message string) *Error {
- return New(b.scope, code.SysInternal, 0, message)
-}
-
-func (b Builder) SysNotImplemented(message string) *Error {
- return New(b.scope, code.SysNotImplemented, 0, message)
-}
-
-func (b Builder) SvcThirdParty(message string) *Error {
- return New(b.scope, code.SvcThirdParty, 0, message)
-}
diff --git a/old/backend/internal/library/exa/client.go b/old/backend/internal/library/exa/client.go
deleted file mode 100644
index c2a6eb7..0000000
--- a/old/backend/internal/library/exa/client.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package exa
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-)
-
-const defaultBaseURL = "https://api.exa.ai/search"
-
-type Mode string
-
-const (
- ModeKnowledgeExpand Mode = "knowledge_expand"
- ModeThreadsDiscover Mode = "threads_discover"
-)
-
-type SearchResult struct {
- Title string
- Snippet string
- URL string
- PublishedDate string
- Author string
- HighlightScore float64
-}
-
-type SearchResponse struct {
- Results []SearchResult
- Query string
- Status string // success | unavailable
-}
-
-type Client struct {
- apiKey string
- baseURL string
- http *http.Client
-}
-
-func NewClient(apiKey string) *Client {
- return &Client{
- apiKey: strings.TrimSpace(apiKey),
- baseURL: defaultBaseURL,
- http: &http.Client{
- Timeout: 25 * time.Second,
- },
- }
-}
-
-func (c *Client) Enabled() bool {
- return c != nil && c.apiKey != ""
-}
-
-type SearchOptions struct {
- Query string
- Limit int
- Mode Mode
- UserLocation string
- StartPublishedDate string
-}
-
-func (c *Client) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
- out := SearchResponse{Query: strings.TrimSpace(opts.Query), Status: "unavailable"}
- if !c.Enabled() {
- return out, nil
- }
- if out.Query == "" {
- return out, fmt.Errorf("exa search query is required")
- }
-
- limit := opts.Limit
- if limit <= 0 {
- limit = 5
- }
- if limit > 20 {
- limit = 20
- }
-
- userLocation := strings.TrimSpace(opts.UserLocation)
- if userLocation == "" {
- userLocation = "TW"
- }
-
- body := map[string]any{
- "query": out.Query,
- "type": "auto",
- "numResults": limit,
- "userLocation": userLocation,
- "contents": map[string]any{
- "highlights": true,
- },
- }
- if opts.Mode == ModeThreadsDiscover {
- body["includeDomains"] = []string{"threads.net", "threads.com"}
- }
- if start := strings.TrimSpace(opts.StartPublishedDate); start != "" {
- body["startPublishedDate"] = start
- }
-
- payload, err := json.Marshal(body)
- if err != nil {
- return out, err
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(payload))
- if err != nil {
- return out, err
- }
- req.Header.Set("Accept", "application/json")
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("x-api-key", c.apiKey)
-
- res, err := c.http.Do(req)
- if err != nil {
- return out, nil
- }
- defer res.Body.Close()
-
- if res.StatusCode != http.StatusOK {
- return out, nil
- }
-
- raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return out, nil
- }
-
- var parsed struct {
- Results []struct {
- Title string `json:"title"`
- URL string `json:"url"`
- PublishedDate string `json:"publishedDate"`
- Author string `json:"author"`
- Highlights []string `json:"highlights"`
- HighlightScores []float64 `json:"highlightScores"`
- } `json:"results"`
- }
- if err := json.Unmarshal(raw, &parsed); err != nil {
- return out, nil
- }
-
- threadsOnly := opts.Mode == ModeThreadsDiscover
- for _, item := range parsed.Results {
- rawURL := strings.TrimSpace(item.URL)
- if rawURL == "" {
- continue
- }
- if threadsOnly && !isThreadsURL(rawURL) {
- continue
- }
- snippet := firstHighlight(item.Highlights)
- if snippet == "" {
- snippet = strings.TrimSpace(item.Title)
- }
- score := 0.0
- if len(item.HighlightScores) > 0 {
- score = item.HighlightScores[0]
- }
- out.Results = append(out.Results, SearchResult{
- Title: strings.TrimSpace(item.Title),
- Snippet: snippet,
- URL: rawURL,
- PublishedDate: strings.TrimSpace(item.PublishedDate),
- Author: strings.TrimSpace(item.Author),
- HighlightScore: score,
- })
- if len(out.Results) >= limit {
- break
- }
- }
- out.Status = "success"
- return out, nil
-}
-
-func firstHighlight(items []string) string {
- for _, item := range items {
- if trimmed := strings.TrimSpace(item); trimmed != "" {
- return trimmed
- }
- }
- return ""
-}
-
-func isThreadsURL(raw string) bool {
- lower := strings.ToLower(raw)
- return strings.Contains(lower, "threads.com") || strings.Contains(lower, "threads.net")
-}
diff --git a/old/backend/internal/library/exa/client_test.go b/old/backend/internal/library/exa/client_test.go
deleted file mode 100644
index 9970147..0000000
--- a/old/backend/internal/library/exa/client_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package exa
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-func TestSearchParsesHighlights(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("x-api-key") != "test-key" {
- t.Fatalf("missing api key header")
- }
- _ = json.NewEncoder(w).Encode(map[string]any{
- "results": []map[string]any{
- {
- "title": "Threads post",
- "url": "https://www.threads.net/@alice/post/abc123",
- "highlights": []string{"這是一則測試貼文內容"},
- "highlightScores": []float64{0.82},
- },
- },
- })
- }))
- defer server.Close()
-
- client := NewClient("test-key")
- client.baseURL = server.URL
-
- res, err := client.Search(context.Background(), SearchOptions{
- Query: "Threads 貼文",
- Limit: 5,
- Mode: ModeThreadsDiscover,
- })
- if err != nil {
- t.Fatalf("search failed: %v", err)
- }
- if res.Status != "success" || len(res.Results) != 1 {
- t.Fatalf("unexpected response: %+v", res)
- }
- if res.Results[0].Snippet != "這是一則測試貼文內容" {
- t.Fatalf("unexpected snippet: %q", res.Results[0].Snippet)
- }
-}
diff --git a/old/backend/internal/library/formula/analyze_pasted.go b/old/backend/internal/library/formula/analyze_pasted.go
deleted file mode 100644
index af422e7..0000000
--- a/old/backend/internal/library/formula/analyze_pasted.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package formula
-
-import (
- "context"
- "fmt"
- "regexp"
- "strings"
-
- libllmjson "haixun-backend/internal/library/llmjson"
- libownpost "haixun-backend/internal/library/ownpost"
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-var pastedFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-type AnalyzePastedInput struct {
- PostText string
- AuthorName string
- PersonaBlock string
- LikeCount int
- ReplyCount int
-}
-
-type PastedAnalysis struct {
- Summary string `json:"summary"`
- Wins []string `json:"wins"`
- Improvements []string `json:"improvements"`
- Formula string `json:"formula"`
- PostTemplate string `json:"post_template"`
- HookPattern string `json:"hook_pattern"`
- Structure string `json:"structure"`
- ReplicationTips []string `json:"replication_tips"`
- Avoid []string `json:"avoid"`
-}
-
-func BuildAnalyzePastedPrompt(in AnalyzePastedInput) (system string, user string) {
- personaBlock := strings.TrimSpace(in.PersonaBlock)
- if personaBlock == "" {
- personaBlock = "(未指定人設,請依貼文本身語氣分析)"
- }
- postText := strings.TrimSpace(in.PostText)
- if postText == "" {
- postText = "(貼文內容未提供)"
- }
- author := strings.TrimSpace(in.AuthorName)
- if author == "" {
- author = "匿名"
- }
-
- system = strings.TrimSpace(`
-你是 Threads / Instagram 貼文結構分析師。任務是拆解「外部貼文」哪些地方可借鏡,再說明如何用指定人設的語言指紋重新說一次;不要產生會讓文案機械的填空模板。
-
-規則:
-- 用台灣繁體中文。
-- formula:整理可借鏡的敘事/資訊推進方式,但要提醒不可照搬句型。
-- post_template:不要寫填空骨架;改寫成「此人設可採用的鬆結構與注意事項」。
-- replication_tips:重點放在如何換成人設語言,包括段落節奏、標點換行、知識轉譯與 CTA。
-- 只輸出一個 JSON 物件,不要 markdown,欄位:
- summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
-- formula / structure / hook_pattern 必須是字串,不要用陣列或巢狀物件
-- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`)
-
- var b strings.Builder
- b.WriteString("【人設脈絡】\n")
- b.WriteString(personaBlock)
- b.WriteString("\n\n【貼文】\n作者:")
- b.WriteString(author)
- if in.LikeCount > 0 || in.ReplyCount > 0 {
- b.WriteString(fmt.Sprintf(" · %d 讚 · %d 留言", in.LikeCount, in.ReplyCount))
- }
- b.WriteString("\n")
- b.WriteString(postText)
- b.WriteString("\n\n請分析爆款結構並輸出 JSON。")
- user = b.String()
- return system, user
-}
-
-func AnalyzePasted(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in AnalyzePastedInput) (*PastedAnalysis, error) {
- system, user := BuildAnalyzePastedPrompt(in)
- temp := 0.65
- req.System = system
- req.Messages = []domai.Message{{Role: "user", Content: user}}
- req.Temperature = &temp
- out, err := ai.GenerateText(ctx, req)
- if err != nil {
- return nil, err
- }
- return ParsePastedAnalysis(out.Text)
-}
-
-func ParsePastedAnalysis(raw string) (*PastedAnalysis, error) {
- payload, err := extractPastedJSON(raw)
- if err != nil {
- return nil, err
- }
- var flex map[string]any
- if err := libllmjson.Unmarshal(payload, &flex); err != nil {
- return nil, fmt.Errorf("parse pasted formula json: %w", err)
- }
- review := pastedAnalysisFromMap(flex)
- if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
- return nil, fmt.Errorf("AI 未回傳有效公式內容")
- }
- return review, nil
-}
-
-func FromOwnPostReview(review *libownpost.PostFormulaReview) *PastedAnalysis {
- if review == nil {
- return nil
- }
- return &PastedAnalysis{
- Summary: review.Summary,
- Wins: review.Wins,
- Improvements: review.Improvements,
- Formula: review.Formula,
- PostTemplate: review.PostTemplate,
- HookPattern: review.HookPattern,
- Structure: review.Structure,
- ReplicationTips: review.ReplicationTips,
- Avoid: review.Avoid,
- }
-}
-
-func FromViralAnalysis(hook, structure, emotion, strategy string, takeaways []string) *PastedAnalysis {
- summary := strings.TrimSpace(emotion)
- if summary == "" {
- summary = "爆款結構分析"
- }
- formula := strings.TrimSpace(strategy)
- if structure != "" {
- if formula != "" {
- formula = structure + " → " + formula
- } else {
- formula = structure
- }
- }
- template := ""
- if hook != "" && structure != "" {
- template = "[" + hook + "] → [" + structure + "] → [你的觀點] → [互動收尾]"
- }
- return &PastedAnalysis{
- Summary: summary,
- Wins: takeaways,
- Formula: formula,
- PostTemplate: template,
- HookPattern: hook,
- Structure: structure,
- ReplicationTips: takeaways,
- }
-}
-
-func extractPastedJSON(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := pastedFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("formula output missing json object")
- }
- return []byte(raw[start : end+1]), nil
-}
diff --git a/old/backend/internal/library/formula/generate_from_formula.go b/old/backend/internal/library/formula/generate_from_formula.go
deleted file mode 100644
index 200db66..0000000
--- a/old/backend/internal/library/formula/generate_from_formula.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package formula
-
-import (
- "context"
- "fmt"
- "regexp"
- "strings"
-
- "haixun-backend/internal/library/copyvoice"
- libllmjson "haixun-backend/internal/library/llmjson"
- "haixun-backend/internal/library/threadspost"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
-)
-
-var generatedDraftFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-type GenerateInput struct {
- Topic string
- Brief string
- PersonaBlock string
- ResearchNotes string
- Formula cfdom.FormulaSummary
-}
-
-type GeneratedDraft struct {
- Text string `json:"text"`
- TopicTag string `json:"topic_tag"`
- Hook string `json:"hook"`
- Angle string `json:"angle"`
- Rationale string `json:"rationale"`
- StructureNotes string `json:"structure_notes"`
-}
-
-func BuildGenerateSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads / Instagram 貼文代筆。寫出符合人設語言指紋、可直接發布的完整新貼文,不是解釋公式,也不是填空模板。
-
-` + copyvoice.SystemRules() + `
-
-規則:
-- 用台灣繁體中文,符合人設語氣。
-- 正文素材以【主題】、【補充】、【周邊知識參考】為準;人設不得改變題材。
-- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。
-- text 必須是一篇完整自然的 Threads 貼文,可直接複製發布。
-- 不可以出現「公式、模板、Hook、情境、轉折、觀點、收尾、[括號]、步驟、結構」等後台分析字眼。
-- 不可以輸出填空骨架,不可以照抄參考原文。
-- 有參考原文時:只學資訊密度、情緒力度與可用結構;最後仍要換成人設會講出口的話。
-- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。
-- topic_tag 請產生 1 個適合 Threads 主題標籤的短詞,不含 #,例如「AI工具」「內容創作」「職場」。
-- 只回傳 JSON:text, topic_tag, hook, angle, rationale, structure_notes(繁體中文)。`)
-}
-
-func BuildGenerateUserPrompt(in GenerateInput) string {
- var b strings.Builder
- b.WriteString("【主題】\n")
- b.WriteString(strings.TrimSpace(in.Topic))
- if brief := strings.TrimSpace(in.Brief); brief != "" {
- b.WriteString("\n補充:")
- b.WriteString(brief)
- }
- b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n")
- b.WriteString(strings.TrimSpace(in.PersonaBlock))
- b.WriteString("\n")
- b.WriteString(copyvoice.PersonaCalibrationNote())
- b.WriteString("\n\n【這篇爆文的故事感(只感受情緒與敘事,禁止照搬句型或填空)】\n")
- b.WriteString(strings.TrimSpace(in.Formula.Formula))
- if structure := strings.TrimSpace(in.Formula.Structure); structure != "" {
- b.WriteString("\n\n敘事推進參考(勿照搬句型):")
- b.WriteString(structure)
- }
- if len(in.Formula.Avoid) > 0 {
- b.WriteString("\n\n【避免】\n")
- b.WriteString(strings.Join(in.Formula.Avoid, "\n"))
- }
- if notes := strings.TrimSpace(in.ResearchNotes); notes != "" {
- b.WriteString("\n\n【周邊知識參考】\n")
- b.WriteString(notes)
- }
- if src := strings.TrimSpace(in.Formula.SourcePostText); src != "" {
- if note := threadspost.MimicLengthGuidance(src); note != "" {
- b.WriteString("\n\n")
- b.WriteString(note)
- }
- b.WriteString("\n\n【參考原文(僅學結構與篇幅,勿抄內容)】\n")
- b.WriteString(src)
- }
- b.WriteString("\n\n本次敘事切入:")
- b.WriteString(copyvoice.NarrativeLens(in.Topic))
- b.WriteString("\n\n請產出一篇可直接發布的 Threads 貼文 JSON。text 欄位只能放貼文正文,不要放分析、標題、公式、模板或填空欄位。topic_tag 請只放標籤文字,不要加 #。")
- return b.String()
-}
-
-func GenerateDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in GenerateInput) (*GeneratedDraft, error) {
- temp := 0.82
- req.System = BuildGenerateSystemPrompt()
- req.Messages = []domai.Message{{Role: "user", Content: BuildGenerateUserPrompt(in)}}
- req.Temperature = &temp
- out, err := ai.GenerateText(ctx, req)
- if err != nil {
- return nil, err
- }
- return ParseGeneratedDraft(out.Text)
-}
-
-func extractGeneratedDraftJSON(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := generatedDraftFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("generate output missing json object")
- }
- return []byte(raw[start : end+1]), nil
-}
-
-func ParseGeneratedDraft(raw string) (*GeneratedDraft, error) {
- payload, err := extractGeneratedDraftJSON(raw)
- if err != nil {
- return nil, err
- }
- var draft GeneratedDraft
- if err := libllmjson.Unmarshal(payload, &draft); err != nil {
- return nil, fmt.Errorf("parse generated draft: %w", err)
- }
- draft.Text = threadspost.FormatDraftText(draft.Text)
- draft.TopicTag = normalizeTopicTag(draft.TopicTag)
- if draft.Text == "" {
- return nil, fmt.Errorf("generated draft missing text")
- }
- badTokens := []string{"[", "]", "【", "】", "Hook", "公式", "模板", "情境→", "轉折→", "觀點→", "收尾", "步驟"}
- for _, token := range badTokens {
- if strings.Contains(draft.Text, token) {
- return nil, fmt.Errorf("generated draft looks like a template, not a publishable post")
- }
- }
- return &draft, nil
-}
-
-func normalizeTopicTag(value string) string {
- value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
- if len([]rune(value)) > 40 {
- runes := []rune(value)
- value = string(runes[:40])
- }
- return value
-}
diff --git a/old/backend/internal/library/formula/generate_from_formula_test.go b/old/backend/internal/library/formula/generate_from_formula_test.go
deleted file mode 100644
index 94f0745..0000000
--- a/old/backend/internal/library/formula/generate_from_formula_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package formula
-
-import "testing"
-
-func TestParseGeneratedDraftWithCodeFence(t *testing.T) {
- raw := "```json\n{\"text\":\"第一句\\n第二句\",\"topic_tag\":\"AI工具\",\"hook\":\"\",\"angle\":\"\",\"rationale\":\"\",\"structure_notes\":\"\"}\n```"
- got, err := ParseGeneratedDraft(raw)
- if err != nil {
- t.Fatalf("ParseGeneratedDraft() err = %v", err)
- }
- if got.Text == "" {
- t.Fatal("expected text")
- }
-}
diff --git a/old/backend/internal/library/formula/jsonflex.go b/old/backend/internal/library/formula/jsonflex.go
deleted file mode 100644
index 0da094c..0000000
--- a/old/backend/internal/library/formula/jsonflex.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package formula
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-)
-
-func coerceString(value any) string {
- switch v := value.(type) {
- case nil:
- return ""
- case string:
- return strings.TrimSpace(v)
- case json.Number:
- return strings.TrimSpace(v.String())
- case float64:
- if v == float64(int64(v)) {
- return fmt.Sprintf("%d", int64(v))
- }
- return fmt.Sprintf("%v", v)
- case bool:
- if v {
- return "true"
- }
- return "false"
- case []any:
- parts := make([]string, 0, len(v))
- for _, item := range v {
- if s := coerceString(item); s != "" {
- parts = append(parts, s)
- }
- }
- return strings.Join(parts, " → ")
- case map[string]any:
- if text, ok := v["text"].(string); ok {
- return strings.TrimSpace(text)
- }
- b, err := json.Marshal(v)
- if err != nil {
- return ""
- }
- return strings.TrimSpace(string(b))
- default:
- return strings.TrimSpace(fmt.Sprint(v))
- }
-}
-
-func coerceStringSlice(value any) []string {
- switch v := value.(type) {
- case nil:
- return nil
- case string:
- s := strings.TrimSpace(v)
- if s == "" {
- return nil
- }
- return []string{s}
- case []any:
- out := make([]string, 0, len(v))
- for _, item := range v {
- if s := coerceString(item); s != "" {
- out = append(out, s)
- }
- }
- return out
- case []string:
- out := make([]string, 0, len(v))
- for _, item := range v {
- if s := strings.TrimSpace(item); s != "" {
- out = append(out, s)
- }
- }
- return out
- default:
- if s := coerceString(v); s != "" {
- return []string{s}
- }
- return nil
- }
-}
-
-func pastedAnalysisFromMap(raw map[string]any) *PastedAnalysis {
- return &PastedAnalysis{
- Summary: coerceString(raw["summary"]),
- Wins: coerceStringSlice(raw["wins"]),
- Improvements: coerceStringSlice(raw["improvements"]),
- Formula: coerceString(raw["formula"]),
- PostTemplate: coerceString(raw["post_template"]),
- HookPattern: coerceString(raw["hook_pattern"]),
- Structure: coerceString(raw["structure"]),
- ReplicationTips: coerceStringSlice(raw["replication_tips"]),
- Avoid: coerceStringSlice(raw["avoid"]),
- }
-}
diff --git a/old/backend/internal/library/formula/jsonflex_test.go b/old/backend/internal/library/formula/jsonflex_test.go
deleted file mode 100644
index f34169e..0000000
--- a/old/backend/internal/library/formula/jsonflex_test.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package formula
-
-import "testing"
-
-func TestCoerceStringFromArray(t *testing.T) {
- got := coerceString([]any{"開場", "故事", "收尾"})
- want := "開場 → 故事 → 收尾"
- if got != want {
- t.Fatalf("coerceString() = %q, want %q", got, want)
- }
-}
-
-func TestParsePastedAnalysisFlexibleStructure(t *testing.T) {
- raw := `{"summary":"測試","formula":"步驟","structure":["Hook","轉折","觀點"],"wins":["節奏好"]}`
- got, err := ParsePastedAnalysis(raw)
- if err != nil {
- t.Fatalf("ParsePastedAnalysis() err = %v", err)
- }
- if got.Structure != "Hook → 轉折 → 觀點" {
- t.Fatalf("Structure = %q", got.Structure)
- }
-}
diff --git a/old/backend/internal/library/knowledge/bootstrap.go b/old/backend/internal/library/knowledge/bootstrap.go
deleted file mode 100644
index 9f74c7c..0000000
--- a/old/backend/internal/library/knowledge/bootstrap.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "unicode/utf8"
-
- "github.com/google/uuid"
-)
-
-const (
- maxPatrolLabelRunes = 14
- minBreadthNodeCount = 12
-)
-
-func SupplementGraphFromResearchMap(g *Graph, seed string, pillars, questions []string) {
- if g == nil {
- return
- }
- seen := nodeLabelSet(g.Nodes)
- ensureSeedCore(g, seed, seen)
- for _, pillar := range pillars {
- if len(g.Nodes) >= 32 {
- break
- }
- addBreadthNode(g, strings.TrimSpace(pillar), 1, "symptom", seen, pillar, "")
- }
- for _, question := range questions {
- if len(g.Nodes) >= 32 {
- break
- }
- q := strings.TrimSpace(question)
- if q == "" {
- continue
- }
- label := patrolLabelFromQuestion(q)
- addBreadthNode(g, label, 2, "pain", seen, q, defaultPlacementForQuestion(q))
- }
-}
-
-func ensureSeedCore(g *Graph, seed string, seen map[string]struct{}) {
- seed = strings.TrimSpace(seed)
- if seed == "" {
- return
- }
- if _, ok := seen[strings.ToLower(seed)]; ok {
- return
- }
- g.Nodes = append([]Node{{
- ID: uuid.NewString(),
- Label: seed,
- NodeKind: "pain",
- Type: "core",
- Layer: 0,
- Relation: "核心種子主題,使用者圍繞此困擾在社群發文求助",
- PlacementValue: "核心討論串最常求推薦與真實心得,適合以產品使用經驗自然回覆",
- ProductFitScore: 90,
- }}, g.Nodes...)
- seen[strings.ToLower(seed)] = struct{}{}
-}
-
-func addBreadthNode(g *Graph, label string, layer int, kind string, seen map[string]struct{}, relation, placement string) {
- label = strings.TrimSpace(label)
- if label == "" {
- return
- }
- key := strings.ToLower(label)
- if _, ok := seen[key]; ok {
- return
- }
- if relation == "" {
- relation = "與種子主題相關的「" + label + "」討論,常見於 Threads 求助串"
- }
- if placement == "" {
- placement = "這類帖常求產品推薦或使用心得,適合以自身經驗自然回覆"
- }
- g.Nodes = append(g.Nodes, Node{
- ID: uuid.NewString(),
- Label: label,
- NodeKind: kind,
- Type: breadthNodeType(layer, kind),
- Layer: layer,
- Relation: relation,
- PlacementValue: placement,
- ProductFitScore: defaultProductFit(kind, layer),
- })
- seen[key] = struct{}{}
-}
-
-func breadthNodeType(layer int, kind string) string {
- if layer == 0 {
- return "core"
- }
- if kind == "cause" {
- return "cause"
- }
- if kind == "symptom" {
- return "symptom"
- }
- return "mechanism"
-}
-
-func nodeLabelSet(nodes []Node) map[string]struct{} {
- seen := map[string]struct{}{}
- for _, node := range nodes {
- label := strings.ToLower(strings.TrimSpace(node.Label))
- if label != "" {
- seen[label] = struct{}{}
- }
- }
- return seen
-}
-
-func patrolLabelFromQuestion(question string) string {
- question = strings.TrimSpace(question)
- if question == "" {
- return ""
- }
- if utf8.RuneCountInString(question) <= maxPatrolLabelRunes {
- return question
- }
- runes := []rune(question)
- return string(runes[:maxPatrolLabelRunes])
-}
-
-func defaultPlacementForQuestion(question string) string {
- return "受眾常這樣發文求助,適合在留言區以產品使用經驗回覆「" + strings.TrimSpace(question) + "」這類問題"
-}
-
-func GraphNeedsBootstrap(g Graph) bool {
- return len(g.Nodes) < minBreadthNodeCount
-}
diff --git a/old/backend/internal/library/knowledge/bootstrap_test.go b/old/backend/internal/library/knowledge/bootstrap_test.go
deleted file mode 100644
index 55b0288..0000000
--- a/old/backend/internal/library/knowledge/bootstrap_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestSupplementGraphFromResearchMap(t *testing.T) {
- graph := Graph{Seed: "化療 沐浴乳", Nodes: []Node{}}
- pillars := []string{
- "化療皮膚敏感無香沐浴乳",
- "乳癌病友沐浴用品挑選",
- "荷爾蒙治療對香味敏感",
- "癌症康復後換清潔品牌",
- "抗敏無香沐浴乳推薦",
- "化療期間皮膚照護",
- }
- questions := []string{
- "化療後皮膚敏感要換什麼沐浴乳",
- "乳癌治療中不能用有香味的沐浴乳嗎",
- "癌症病人適合用的無香沐浴乳推薦",
- "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳",
- "打標靶後對香味很敏感怎麼辦",
- "康復後不想再用有香精的清潔用品",
- "癌症病友都用什麼牌子沐浴乳",
- "化療期間沐浴乳挑選經驗分享",
- }
- SupplementGraphFromResearchMap(&graph, "化療 沐浴乳", pillars, questions)
- if len(graph.Nodes) < 12 {
- t.Fatalf("expected >=12 nodes, got %d", len(graph.Nodes))
- }
- DeriveSearchTagsFromGraph(&graph, PatrolTagInput{
- ProductName: "抗敏無香沐浴乳",
- Questions: questions,
- Pillars: pillars,
- })
- if graph.PainTagCount < 8 {
- t.Fatalf("expected pain tags, got %d", graph.PainTagCount)
- }
- for _, node := range graph.Nodes {
- if len(node.DerivedTags.Relevance)+len(node.DerivedTags.Recency) == 0 {
- continue
- }
- for _, tag := range append(node.DerivedTags.Relevance, node.DerivedTags.Recency...) {
- if len([]rune(tag)) < 6 {
- t.Fatalf("tag too short %q", tag)
- }
- }
- }
-}
diff --git a/old/backend/internal/library/knowledge/brave_collect.go b/old/backend/internal/library/knowledge/brave_collect.go
deleted file mode 100644
index f18e97b..0000000
--- a/old/backend/internal/library/knowledge/brave_collect.go
+++ /dev/null
@@ -1,379 +0,0 @@
-package knowledge
-
-import (
- "context"
- "strings"
- "sync"
-
- "haixun-backend/internal/library/websearch"
-)
-
-type BraveSearchLocale struct {
- Country string
- SearchLang string
- UserLocation string
-}
-
-type BraveCollectConfig struct {
- ResultsPerQuery int
- MinSourcesBeforeStop int
- MaxSourcesCap int
- Concurrency int
-}
-
-const minUsefulSourceRunes = 24
-
-func BraveCollectConfigFromQueryCfg(cfg queryConfig) BraveCollectConfig {
- out := BraveCollectConfig{
- ResultsPerQuery: cfg.ResultsPerQuery,
- MinSourcesBeforeStop: cfg.MinSourcesBeforeStop,
- MaxSourcesCap: cfg.MaxSourcesCap,
- Concurrency: cfg.BraveCollectConcurrency,
- }
- if out.ResultsPerQuery <= 0 {
- out.ResultsPerQuery = 8
- }
- if out.MinSourcesBeforeStop <= 0 {
- out.MinSourcesBeforeStop = 18
- }
- if out.MaxSourcesCap <= 0 {
- out.MaxSourcesCap = 32
- }
- if out.Concurrency <= 0 {
- out.Concurrency = 4
- }
- return out
-}
-
-func DefaultBraveCollectConfig() BraveCollectConfig {
- cfg, err := loadQueryConfig()
- if err != nil {
- return BraveCollectConfig{ResultsPerQuery: 8, MinSourcesBeforeStop: 18, MaxSourcesCap: 32, Concurrency: 4}
- }
- return BraveCollectConfigFromQueryCfg(cfg)
-}
-
-func CollectBraveSources(
- ctx context.Context,
- client websearch.Client,
- locale BraveSearchLocale,
- queries []string,
- cfg BraveCollectConfig,
- onProgress func(i, total int),
- heartbeat func() error,
-) []BraveSource {
- return CollectWebSources(ctx, client, locale, queries, cfg, onProgress, heartbeat)
-}
-
-func CollectWebSources(
- ctx context.Context,
- client websearch.Client,
- locale BraveSearchLocale,
- queries []string,
- cfg BraveCollectConfig,
- onProgress func(i, total int),
- heartbeat func() error,
-) []BraveSource {
- if client == nil || !client.Enabled() || len(queries) == 0 {
- return nil
- }
- if cfg.Concurrency <= 1 {
- return collectWebSourcesSequential(ctx, client, locale, queries, cfg, onProgress, heartbeat)
- }
- return collectWebSourcesParallel(ctx, client, locale, queries, cfg, onProgress, heartbeat)
-}
-
-func collectWebSourcesSequential(
- ctx context.Context,
- client websearch.Client,
- locale BraveSearchLocale,
- queries []string,
- cfg BraveCollectConfig,
- onProgress func(i, total int),
- heartbeat func() error,
-) []BraveSource {
- capHint := cfg.MaxSourcesCap
- if est := len(queries) * cfg.ResultsPerQuery; est < capHint {
- capHint = est
- }
- out := make([]BraveSource, 0, capHint)
- seenURL := map[string]struct{}{}
-
- for i, query := range queries {
- if shouldStopCollect(out, queries, cfg) {
- break
- }
- if heartbeat != nil {
- if err := heartbeat(); err != nil {
- return out
- }
- }
- appendBraveResults(&out, seenURL, query, searchWebQuery(ctx, client, locale, query, cfg.ResultsPerQuery), cfg.MaxSourcesCap)
- if onProgress != nil {
- onProgress(i, len(queries))
- }
- }
- return out
-}
-
-type braveCollectState struct {
- cfg BraveCollectConfig
- mu sync.Mutex
- out []BraveSource
- seenURL map[string]struct{}
- stop bool
-}
-
-func (s *braveCollectState) shouldStop(queries []string, cfg BraveCollectConfig) bool {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.stop {
- return true
- }
- if shouldStopCollect(s.out, queries, cfg) {
- s.stop = true
- return true
- }
- return false
-}
-
-func (s *braveCollectState) appendResults(query string, items []BraveSource, queries []string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.stop {
- return
- }
- appendBraveResults(&s.out, s.seenURL, query, items, s.cfg.MaxSourcesCap)
- if shouldStopCollect(s.out, queries, s.cfg) {
- s.stop = true
- }
-}
-
-func (s *braveCollectState) snapshot() []BraveSource {
- s.mu.Lock()
- defer s.mu.Unlock()
- return append([]BraveSource(nil), s.out...)
-}
-
-func collectWebSourcesParallel(
- ctx context.Context,
- client websearch.Client,
- locale BraveSearchLocale,
- queries []string,
- cfg BraveCollectConfig,
- onProgress func(i, total int),
- heartbeat func() error,
-) []BraveSource {
- state := &braveCollectState{
- cfg: cfg,
- out: make([]BraveSource, 0, cfg.MaxSourcesCap),
- seenURL: map[string]struct{}{},
- }
-
- workers := cfg.Concurrency
- if workers > len(queries) {
- workers = len(queries)
- }
- if workers <= 0 {
- workers = 1
- }
-
- completed := 0
- for next := 0; next < len(queries); {
- if state.shouldStop(queries, cfg) {
- break
- }
- batchEnd := next + workers
- if batchEnd > len(queries) {
- batchEnd = len(queries)
- }
-
- type queryResult struct {
- index int
- items []BraveSource
- }
- results := make(chan queryResult, batchEnd-next)
- var wg sync.WaitGroup
- for i := next; i < batchEnd; i++ {
- if heartbeat != nil {
- if err := heartbeat(); err != nil {
- return state.snapshot()
- }
- }
- query := queries[i]
- wg.Add(1)
- go func(index int, q string) {
- defer wg.Done()
- results <- queryResult{
- index: index,
- items: searchWebQuery(ctx, client, locale, q, cfg.ResultsPerQuery),
- }
- }(i, query)
- }
- wg.Wait()
- close(results)
-
- ordered := make([]queryResult, batchEnd-next)
- for result := range results {
- ordered[result.index-next] = result
- }
- for _, result := range ordered {
- state.appendResults(queries[result.index], result.items, queries)
- completed++
- if onProgress != nil {
- onProgress(completed-1, len(queries))
- }
- }
- next = batchEnd
- }
- return state.snapshot()
-}
-
-func shouldStopCollect(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
- if len(out) >= cfg.MaxSourcesCap {
- return true
- }
- return sourcesAreSufficient(out, queries, cfg)
-}
-
-func sourcesAreSufficient(out []BraveSource, queries []string, cfg BraveCollectConfig) bool {
- if cfg.MinSourcesBeforeStop <= 0 {
- return len(out) > 0
- }
- stats := sourceStats(out)
- if stats.UniqueURLs < cfg.MinSourcesBeforeStop {
- return false
- }
- minQueryCoverage := 1
- if len(queries) > 1 {
- minQueryCoverage = 2
- }
- if stats.QueryCoverage < minQueryCoverage {
- return false
- }
- minUseful := (cfg.MinSourcesBeforeStop * 2) / 3
- if minUseful < 1 {
- minUseful = 1
- }
- if stats.UsefulSources < minUseful {
- return false
- }
- return stats.TextRunes >= cfg.MinSourcesBeforeStop*minUsefulSourceRunes
-}
-
-type collectSourceStats struct {
- UniqueURLs int
- QueryCoverage int
- UsefulSources int
- TextRunes int
-}
-
-func sourceStats(items []BraveSource) collectSourceStats {
- seenURL := map[string]struct{}{}
- seenQuery := map[string]struct{}{}
- stats := collectSourceStats{}
- for _, item := range items {
- url := strings.TrimSpace(item.URL)
- if url == "" {
- continue
- }
- if _, ok := seenURL[url]; ok {
- continue
- }
- seenURL[url] = struct{}{}
- query := strings.TrimSpace(item.Query)
- if query != "" {
- seenQuery[query] = struct{}{}
- }
- textRunes := len([]rune(strings.TrimSpace(item.Title))) + len([]rune(strings.TrimSpace(item.Snippet)))
- stats.TextRunes += textRunes
- if textRunes >= minUsefulSourceRunes {
- stats.UsefulSources++
- }
- }
- stats.UniqueURLs = len(seenURL)
- stats.QueryCoverage = len(seenQuery)
- return stats
-}
-
-func searchWebQuery(
- ctx context.Context,
- client websearch.Client,
- locale BraveSearchLocale,
- query string,
- limit int,
-) []BraveSource {
- res, _ := client.Search(ctx, websearch.SearchOptions{
- Query: query,
- Limit: limit,
- Mode: websearch.ModeKnowledgeExpand,
- Country: locale.Country,
- SearchLang: locale.SearchLang,
- UserLocation: locale.UserLocation,
- })
- items := make([]BraveSource, 0, len(res.Results))
- for _, item := range res.Results {
- url := strings.TrimSpace(item.URL)
- if url == "" {
- continue
- }
- items = append(items, BraveSource{
- Query: query,
- Snippet: item.Snippet,
- URL: url,
- Title: item.Title,
- })
- }
- return items
-}
-
-func appendBraveResults(out *[]BraveSource, seenURL map[string]struct{}, query string, items []BraveSource, max int) {
- for _, item := range items {
- if max > 0 && len(*out) >= max {
- return
- }
- url := strings.TrimSpace(item.URL)
- if url == "" {
- continue
- }
- if _, ok := seenURL[url]; ok {
- continue
- }
- seenURL[url] = struct{}{}
- if item.Query == "" {
- item.Query = query
- }
- *out = append(*out, item)
- }
-}
-
-func MergeBraveSources(chunks ...[]BraveSource) []BraveSource {
- seen := map[string]struct{}{}
- out := make([]BraveSource, 0)
- for _, chunk := range chunks {
- for _, item := range chunk {
- url := strings.TrimSpace(item.URL)
- if url == "" {
- continue
- }
- if _, ok := seen[url]; ok {
- continue
- }
- seen[url] = struct{}{}
- out = append(out, item)
- }
- }
- return out
-}
-
-func uniqueSourceCount(items []BraveSource) int {
- seen := map[string]struct{}{}
- for _, item := range items {
- url := strings.TrimSpace(item.URL)
- if url == "" {
- continue
- }
- seen[url] = struct{}{}
- }
- return len(seen)
-}
diff --git a/old/backend/internal/library/knowledge/brave_collect_test.go b/old/backend/internal/library/knowledge/brave_collect_test.go
deleted file mode 100644
index 509b251..0000000
--- a/old/backend/internal/library/knowledge/brave_collect_test.go
+++ /dev/null
@@ -1,197 +0,0 @@
-package knowledge
-
-import (
- "context"
- "fmt"
- "sync"
- "testing"
-
- "haixun-backend/internal/library/websearch"
-)
-
-func TestUniqueSourceCount(t *testing.T) {
- count := uniqueSourceCount([]BraveSource{
- {URL: "https://a.example"},
- {URL: "https://a.example"},
- {URL: "https://b.example"},
- {URL: ""},
- })
- if count != 2 {
- t.Fatalf("expected 2 unique urls, got %d", count)
- }
-}
-
-func TestSourcesAreSufficientRequiresProcessedQuality(t *testing.T) {
- items := make([]BraveSource, 0, 14)
- for i := 0; i < 14; i++ {
- items = append(items, BraveSource{
- Query: "q1",
- URL: fmt.Sprintf("https://example.com/%d", i),
- Title: "薄",
- })
- }
- cfg := BraveCollectConfig{MinSourcesBeforeStop: 14, MaxSourcesCap: 22}
- if sourcesAreSufficient(items, []string{"q1", "q2"}, cfg) {
- t.Fatal("thin same-query results should not be considered sufficient")
- }
-
- for i := range items {
- items[i].Snippet = "這是一段整理後足以支撐判斷的搜尋摘要內容,包含痛點與情境"
- if i >= 7 {
- items[i].Query = "q2"
- }
- }
- if !sourcesAreSufficient(items, []string{"q1", "q2"}, cfg) {
- t.Fatal("diverse useful results should be considered sufficient")
- }
-}
-
-func TestCollectWebSourcesParallelStopsAfterSufficientBatch(t *testing.T) {
- client := &fakeSearchClient{results: map[string][]websearch.SearchResult{}}
- for qi := 0; qi < 5; qi++ {
- query := fmt.Sprintf("q%d", qi+1)
- for ri := 0; ri < 7; ri++ {
- client.results[query] = append(client.results[query], websearch.SearchResult{
- URL: fmt.Sprintf("https://example.com/%s/%d", query, ri),
- Title: "有用來源",
- Snippet: "這是一段整理後足以支撐判斷的搜尋摘要內容,包含痛點與情境",
- })
- }
- }
-
- out := CollectWebSources(
- context.Background(),
- client,
- BraveSearchLocale{},
- []string{"q1", "q2", "q3", "q4", "q5"},
- BraveCollectConfig{ResultsPerQuery: 7, MinSourcesBeforeStop: 14, MaxSourcesCap: 30, Concurrency: 2},
- nil,
- nil,
- )
- if len(out) != 14 {
- t.Fatalf("expected first batch to provide 14 sources, got %d", len(out))
- }
- if got := client.callCount(); got != 2 {
- t.Fatalf("expected collection to stop after first sufficient batch, called %d queries", got)
- }
-}
-
-func TestPlanQueriesHybridBudget(t *testing.T) {
- queries := PlanQueries(PlanInput{
- Seed: "敏感肌",
- TargetAudience: "孕婦",
- Questions: []string{"q1", "q2", "q3", "q4"},
- Pillars: []string{"p1", "p2", "p3"},
- L1Labels: []string{"a", "b", "c", "d"},
- Strategy: ExpandStrategyHybrid,
- })
- if len(queries) > 4 {
- t.Fatalf("hybrid should cap at 4 queries, got %d: %v", len(queries), queries)
- }
- if len(queries) == 0 {
- t.Fatal("expected some queries")
- }
-}
-
-func TestPlanQueriesPrioritizesPatrolKeywords(t *testing.T) {
- queries := PlanQueries(PlanInput{
- Seed: "敏感肌",
- PatrolKeywords: []string{"化療 沐浴乳", "無香沐浴乳 推薦"},
- Questions: []string{"很長的受眾提問一", "很長的受眾提問二"},
- Strategy: ExpandStrategyBrave,
- })
- if len(queries) == 0 {
- t.Fatal("expected queries")
- }
- if queries[0] != "化療 沐浴乳" {
- t.Fatalf("expected patrol keyword first, got %q", queries[0])
- }
-}
-
-func TestQueriesExcept(t *testing.T) {
- remaining := QueriesExcept(
- []string{"a", "b", "c", "d"},
- []string{"a", "c"},
- )
- if len(remaining) != 2 || remaining[0] != "b" || remaining[1] != "d" {
- t.Fatalf("unexpected remaining: %v", remaining)
- }
-}
-
-func TestPlanBootstrapQueriesSkipsResearchMapFields(t *testing.T) {
- full := PlanQueries(PlanInput{
- Seed: "敏感肌",
- Questions: []string{"q1"},
- Pillars: []string{"p1"},
- Strategy: ExpandStrategyBrave,
- })
- bootstrap := PlanBootstrapQueries(PlanInput{
- Seed: "敏感肌",
- Questions: []string{"q1"},
- Pillars: []string{"p1"},
- Strategy: ExpandStrategyBrave,
- })
- if len(bootstrap) == 0 {
- t.Fatal("expected bootstrap queries")
- }
- for _, q := range bootstrap {
- if q == "q1" || q == "p1 請問" {
- t.Fatalf("bootstrap should not include research map query %q", q)
- }
- }
- if len(full) <= len(bootstrap) {
- t.Fatalf("full plan should include more queries than bootstrap: full=%v bootstrap=%v", full, bootstrap)
- }
-}
-
-func TestMergeBraveSourcesDedupesURLs(t *testing.T) {
- merged := MergeBraveSources(
- []BraveSource{{URL: "https://a.example", Query: "q1"}},
- []BraveSource{{URL: "https://a.example", Query: "q2"}, {URL: "https://b.example", Query: "q3"}},
- )
- if len(merged) != 2 {
- t.Fatalf("expected 2 merged sources, got %d", len(merged))
- }
-}
-
-func TestSupplementalQueriesSkippedForHybrid(t *testing.T) {
- queries := PlanQueries(PlanInput{
- Seed: "敏感肌",
- Supplemental: true,
- Strategy: ExpandStrategyHybrid,
- })
- if len(queries) != 0 {
- t.Fatalf("hybrid supplemental brave should be empty, got %v", queries)
- }
-}
-
-type fakeSearchClient struct {
- mu sync.Mutex
- calls []string
- results map[string][]websearch.SearchResult
-}
-
-func (f *fakeSearchClient) Enabled() bool { return true }
-
-func (f *fakeSearchClient) Provider() websearch.Provider { return websearch.ProviderBrave }
-
-func (f *fakeSearchClient) Search(_ context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- if f.results == nil {
- f.results = map[string][]websearch.SearchResult{}
- }
- f.calls = append(f.calls, opts.Query)
- return websearch.SearchResponse{
- Query: opts.Query,
- Status: "success",
- Provider: websearch.ProviderBrave,
- Results: append([]websearch.SearchResult(nil), f.results[opts.Query]...),
- }, nil
-}
-
-func (f *fakeSearchClient) callCount() int {
- f.mu.Lock()
- defer f.mu.Unlock()
- return len(f.calls)
-}
diff --git a/old/backend/internal/library/knowledge/expand_strategy.go b/old/backend/internal/library/knowledge/expand_strategy.go
deleted file mode 100644
index 6b9733b..0000000
--- a/old/backend/internal/library/knowledge/expand_strategy.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package knowledge
-
-import "strings"
-
-type ExpandStrategy string
-
-const (
- ExpandStrategyBrave ExpandStrategy = "brave"
- ExpandStrategyLLM ExpandStrategy = "llm"
- ExpandStrategyHybrid ExpandStrategy = "hybrid"
-)
-
-func ParseExpandStrategy(raw string) ExpandStrategy {
- switch strings.ToLower(strings.TrimSpace(raw)) {
- case string(ExpandStrategyLLM):
- return ExpandStrategyLLM
- case string(ExpandStrategyHybrid):
- return ExpandStrategyHybrid
- default:
- return ExpandStrategyBrave
- }
-}
-
-func (s ExpandStrategy) RequiresWebSearch() bool {
- return s == ExpandStrategyBrave || s == ExpandStrategyHybrid
-}
-
-func (s ExpandStrategy) RequiresBrave() bool {
- return s.RequiresWebSearch()
-}
-
-// UsesSupplementalBrave 廣度補充是否再打第二輪 Brave(hybrid 改由 LLM 補廣度以省 API)。
-func (s ExpandStrategy) UsesSupplementalBrave() bool {
- return s == ExpandStrategyBrave
-}
-
-func (s ExpandStrategy) String() string {
- return string(s)
-}
diff --git a/old/backend/internal/library/knowledge/expand_strategy_test.go b/old/backend/internal/library/knowledge/expand_strategy_test.go
deleted file mode 100644
index 6cc23cb..0000000
--- a/old/backend/internal/library/knowledge/expand_strategy_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestExpandStrategyUsesSupplementalBrave(t *testing.T) {
- if !ExpandStrategyBrave.UsesSupplementalBrave() {
- t.Fatal("brave should use supplemental brave")
- }
- if ExpandStrategyHybrid.UsesSupplementalBrave() {
- t.Fatal("hybrid should skip supplemental brave")
- }
- if ExpandStrategyLLM.UsesSupplementalBrave() {
- t.Fatal("llm should skip supplemental brave")
- }
-}
-
-func TestParseExpandStrategy(t *testing.T) {
- cases := map[string]ExpandStrategy{
- "": ExpandStrategyBrave,
- "brave": ExpandStrategyBrave,
- "BRAVE": ExpandStrategyBrave,
- "llm": ExpandStrategyLLM,
- "hybrid": ExpandStrategyHybrid,
- "unknown": ExpandStrategyBrave,
- }
- for raw, want := range cases {
- if got := ParseExpandStrategy(raw); got != want {
- t.Fatalf("ParseExpandStrategy(%q) = %q, want %q", raw, got, want)
- }
- }
-}
-
-func TestExpandStrategyRequiresBrave(t *testing.T) {
- if !ExpandStrategyBrave.RequiresBrave() || !ExpandStrategyHybrid.RequiresBrave() {
- t.Fatal("brave/hybrid should require brave")
- }
- if ExpandStrategyLLM.RequiresBrave() {
- t.Fatal("llm should not require brave")
- }
-}
diff --git a/old/backend/internal/library/knowledge/graph.go b/old/backend/internal/library/knowledge/graph.go
deleted file mode 100644
index 3a220e7..0000000
--- a/old/backend/internal/library/knowledge/graph.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package knowledge
-
-import "strings"
-
-type DerivedTags struct {
- Relevance []string `json:"relevance"`
- Recency []string `json:"recency"`
-}
-
-type Evidence struct {
- URL string `json:"url"`
- Snippet string `json:"snippet"`
- Query string `json:"query,omitempty"`
-}
-
-type Node struct {
- ID string `json:"id"`
- Label string `json:"label"`
- NodeKind string `json:"nodeKind"` // pain | knowledge | cause | symptom
- Type string `json:"type"` // core | cause | symptom | mechanism
- Layer int `json:"layer"`
- Relation string `json:"relation,omitempty"`
- PlacementValue string `json:"placementValue,omitempty"` // 為何與產品置入相關(完整句子)
- ProductFitScore int `json:"productFitScore"`
- SelectedForScan bool `json:"selectedForScan"`
- PatrolRelevance []string `json:"patrolRelevance,omitempty"`
- PatrolRecency []string `json:"patrolRecency,omitempty"`
- Evidence []Evidence `json:"evidence"`
- DerivedTags DerivedTags `json:"derivedTags"`
-}
-
-type Edge struct {
- From string `json:"from"`
- To string `json:"to"`
- Relation string `json:"relation"`
-}
-
-type BraveSource struct {
- Query string `json:"query"`
- Snippet string `json:"snippet"`
- URL string `json:"url"`
- Title string `json:"title,omitempty"`
-}
-
-type Graph struct {
- Seed string `json:"seed"`
- Nodes []Node `json:"nodes"`
- Edges []Edge `json:"edges"`
- BraveSources []BraveSource `json:"braveSources"`
- PainTagCount int `json:"painTagCount"`
-}
-
-func IsPainNode(node Node) bool {
- switch strings.TrimSpace(node.NodeKind) {
- case "pain", "symptom", "cause":
- return true
- default:
- return false
- }
-}
-
-func CountPainTagCandidates(nodes []Node) int {
- count := 0
- for _, node := range nodes {
- if !IsPainNode(node) {
- continue
- }
- if len(node.DerivedTags.Relevance) > 0 || len(node.DerivedTags.Recency) > 0 {
- count++
- }
- }
- return count
-}
-
-func TotalTagCandidates(nodes []Node) int {
- count := 0
- for _, node := range nodes {
- count += len(node.DerivedTags.Relevance) + len(node.DerivedTags.Recency)
- }
- return count
-}
-
-// PreserveNodeSelectionByLabel reapplies user scan selections after graph regen changes node IDs.
-func PreserveNodeSelectionByLabel(nodes []Node, previous []Node) {
- if len(nodes) == 0 || len(previous) == 0 {
- return
- }
- selected := map[string]struct{}{}
- for _, node := range previous {
- if !node.SelectedForScan {
- continue
- }
- label := strings.ToLower(strings.TrimSpace(node.Label))
- if label != "" {
- selected[label] = struct{}{}
- }
- }
- if len(selected) == 0 {
- return
- }
- for i := range nodes {
- label := strings.ToLower(strings.TrimSpace(nodes[i].Label))
- if _, ok := selected[label]; ok {
- nodes[i].SelectedForScan = true
- }
- }
-}
-
-func NodeByID(nodes []Node, id string) (Node, bool) {
- id = strings.TrimSpace(id)
- for _, node := range nodes {
- if node.ID == id {
- return node, true
- }
- }
- return Node{}, false
-}
diff --git a/old/backend/internal/library/knowledge/graph_rescore.go b/old/backend/internal/library/knowledge/graph_rescore.go
deleted file mode 100644
index ee00c6c..0000000
--- a/old/backend/internal/library/knowledge/graph_rescore.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package knowledge
-
-// RescoreGraphIntentFit recomputes node productFitScore using:
-// 1. weighted intent similarity (product + research map)
-// 2. graph proximity propagation from high-fit core nodes
-// 3. tangential-topic penalty
-func RescoreGraphIntentFit(graph *Graph, in PatrolTagInput) {
- if graph == nil || len(graph.Nodes) == 0 {
- return
- }
- profile := BuildIntentProfile(in)
- intentScores := make([]int, len(graph.Nodes))
- for i, node := range graph.Nodes {
- intentScores[i] = ScoreIntentSimilarity(NodeIntentText(node), profile)
- }
- propagated := propagateIntentAlongGraph(graph.Nodes, graph.Edges, intentScores)
-
- for i := range graph.Nodes {
- node := &graph.Nodes[i]
- semantic := intentScores[i]
- if propagated[i] > semantic {
- semantic = propagated[i]
- }
- llmScore := node.ProductFitScore
- if llmScore <= 0 {
- llmScore = defaultProductFit(node.NodeKind, node.Layer)
- }
-
- layerBlend := 0.5 + float64(minInt(node.Layer, 3))*0.08
- blended := int(float64(semantic)*layerBlend + float64(llmScore)*(1.0-layerBlend))
- if IsTangentialToIntent(NodeIntentText(*node), profile) {
- blended = minInt(blended, 28)
- }
- if blended < 0 {
- blended = 0
- }
- if blended > 100 {
- blended = 100
- }
- node.ProductFitScore = blended
- }
-}
-
-func propagateIntentAlongGraph(nodes []Node, edges []Edge, intentScores []int) []int {
- if len(nodes) == 0 {
- return nil
- }
- idIndex := map[string]int{}
- for i, node := range nodes {
- idIndex[node.ID] = i
- }
- adj := make([][]int, len(nodes))
- addEdge := func(from, to string) {
- fi, okFrom := idIndex[from]
- ti, okTo := idIndex[to]
- if !okFrom || !okTo || fi == ti {
- return
- }
- adj[fi] = append(adj[fi], ti)
- adj[ti] = append(adj[ti], fi)
- }
- for _, edge := range edges {
- addEdge(edge.From, edge.To)
- }
-
- out := make([]int, len(nodes))
- copy(out, intentScores)
- visited := make([]bool, len(nodes))
- type queueItem struct {
- idx int
- dist int
- }
- queue := make([]queueItem, 0, len(nodes))
- for i, score := range intentScores {
- if score >= 55 || nodes[i].Layer == 0 {
- queue = append(queue, queueItem{idx: i, dist: 0})
- visited[i] = true
- }
- }
- for len(queue) > 0 {
- item := queue[0]
- queue = queue[1:]
- if item.dist >= 3 {
- continue
- }
- base := intentScores[item.idx]
- if base <= 0 {
- base = out[item.idx]
- }
- decayed := int(float64(base) * powDecay(0.74, item.dist+1))
- for _, next := range adj[item.idx] {
- if decayed > out[next] {
- out[next] = decayed
- }
- if !visited[next] {
- visited[next] = true
- queue = append(queue, queueItem{idx: next, dist: item.dist + 1})
- }
- }
- }
- return out
-}
-
-func powDecay(base float64, exp int) float64 {
- out := 1.0
- for i := 0; i < exp; i++ {
- out *= base
- }
- return out
-}
diff --git a/old/backend/internal/library/knowledge/graph_selection_test.go b/old/backend/internal/library/knowledge/graph_selection_test.go
deleted file mode 100644
index f80a884..0000000
--- a/old/backend/internal/library/knowledge/graph_selection_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestPreserveNodeSelectionByLabel(t *testing.T) {
- previous := []Node{
- {ID: "old-1", Label: "化療後 皮膚乾", SelectedForScan: true},
- {ID: "old-2", Label: "無關節點", SelectedForScan: false},
- }
- nodes := []Node{
- {ID: "new-1", Label: "化療後 皮膚乾"},
- {ID: "new-2", Label: "新痛點"},
- }
- PreserveNodeSelectionByLabel(nodes, previous)
- if !nodes[0].SelectedForScan {
- t.Fatal("expected first node selection to be preserved by label")
- }
- if nodes[1].SelectedForScan {
- t.Fatal("expected unrelated node to stay unselected")
- }
-}
diff --git a/old/backend/internal/library/knowledge/patrol_input.go b/old/backend/internal/library/knowledge/patrol_input.go
deleted file mode 100644
index 5da4c30..0000000
--- a/old/backend/internal/library/knowledge/patrol_input.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package knowledge
-
-import (
- "encoding/json"
- "strings"
-
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
-)
-
-// PatrolTagInput grounds海巡 tag in研究地圖與品牌/產品輸入。
-type PatrolTagInput struct {
- BrandName string
- ProductName string
- ProductFeatures string
- AudienceSummary string
- TargetAudience string
- MatchTags []string
- Questions []string
- Pillars []string
- PatrolKeywords []string
-}
-
-type productContextFields struct {
- Brand string `json:"brand"`
- Product string `json:"product"`
- Features string `json:"features"`
-}
-
-func parseProductContextFields(raw string) productContextFields {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return productContextFields{}
- }
- var parsed productContextFields
- if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
- return productContextFields{Features: raw}
- }
- parsed.Brand = strings.TrimSpace(parsed.Brand)
- parsed.Product = strings.TrimSpace(parsed.Product)
- parsed.Features = strings.TrimSpace(parsed.Features)
- return parsed
-}
-
-func PatrolTagInputFromBrand(brand *branddomain.BrandSummary, productBrief string) PatrolTagInput {
- if brand == nil {
- return PatrolTagInput{}
- }
- fields := parseProductContextFields(brand.ProductContext)
- productName := productLabelFromBrand(brand)
- if productName == "" {
- productName = fields.Product
- }
- brandName := strings.TrimSpace(brand.DisplayName)
- if brandName == "" {
- brandName = fields.Brand
- }
- features := fields.Features
- if features == "" {
- features = strings.TrimSpace(productBrief)
- }
- if features == "" {
- features = strings.TrimSpace(brand.ProductBrief)
- }
- return PatrolTagInput{
- BrandName: brandName,
- ProductName: productName,
- ProductFeatures: features,
- AudienceSummary: strings.TrimSpace(brand.ResearchMap.AudienceSummary),
- TargetAudience: strings.TrimSpace(brand.TargetAudience),
- MatchTags: matchTagsFromBrand(brand),
- Questions: append([]string{}, brand.ResearchMap.Questions...),
- Pillars: append([]string{}, brand.ResearchMap.Pillars...),
- PatrolKeywords: append([]string{}, brand.ResearchMap.PatrolKeywords...),
- }
-}
-
-// OverlayResearchMap copies topic-level research map fields onto patrol input.
-func OverlayResearchMap(in PatrolTagInput, audienceSummary string, questions, pillars, patrolKeywords []string) PatrolTagInput {
- if strings.TrimSpace(audienceSummary) != "" {
- in.AudienceSummary = strings.TrimSpace(audienceSummary)
- }
- if len(questions) > 0 {
- in.Questions = append([]string{}, questions...)
- }
- if len(pillars) > 0 {
- in.Pillars = append([]string{}, pillars...)
- }
- if len(patrolKeywords) > 0 {
- in.PatrolKeywords = append([]string{}, patrolKeywords...)
- }
- return in
-}
-
-func productLabelFromBrand(brand *branddomain.BrandSummary) string {
- if brand == nil {
- return ""
- }
- productID := strings.TrimSpace(brand.ProductID)
- if productID == "" {
- return ""
- }
- for _, item := range brand.Products {
- if item.ID == productID {
- label := strings.TrimSpace(item.Label)
- if label != "" {
- return label
- }
- return parseProductContextFields(item.ProductContext).Product
- }
- }
- return ""
-}
-
-func matchTagsFromBrand(brand *branddomain.BrandSummary) []string {
- if brand == nil {
- return nil
- }
- productID := strings.TrimSpace(brand.ProductID)
- seen := map[string]struct{}{}
- out := []string{}
- add := func(tag string) {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return
- }
- if _, ok := seen[tag]; ok {
- return
- }
- seen[tag] = struct{}{}
- out = append(out, tag)
- }
- for _, item := range brand.Products {
- if productID != "" && item.ID != productID {
- continue
- }
- for _, tag := range item.MatchTags {
- add(tag)
- }
- if productID != "" {
- break
- }
- }
- return out
-}
-
-func (in PatrolTagInput) HasResearchMap() bool {
- return len(in.Questions) > 0 || len(in.Pillars) > 0 || strings.TrimSpace(in.AudienceSummary) != ""
-}
-
-func (in PatrolTagInput) HasProductContext() bool {
- return strings.TrimSpace(in.ProductName) != "" ||
- strings.TrimSpace(in.ProductFeatures) != "" ||
- len(in.MatchTags) > 0
-}
-
-var patrolCoreValueTokens = []string{
- "敏感", "無香", "抗敏", "低敏", "香精", "香料", "香味", "過敏", "皮膚",
- "化療", "癌症", "乳癌", "荷爾蒙", "標靶", "病友",
-}
-
-func primaryPatrolValueAnchor(in PatrolTagInput) string {
- blob := strings.ToLower(strings.TrimSpace(in.ProductName + " " + in.ProductFeatures + " " +
- strings.Join(in.MatchTags, " ") + " " + strings.Join(in.Pillars, " ") + " " +
- in.AudienceSummary + " " + in.TargetAudience))
- priority := []string{"無香", "抗敏", "敏感", "化療", "癌症", "乳癌", "荷爾蒙", "過敏", "香味", "香精"}
- for _, token := range priority {
- if strings.Contains(blob, token) {
- return token
- }
- }
- for _, token := range patrolCoreValueTokens {
- if strings.Contains(blob, token) {
- return token
- }
- }
- return ""
-}
-
-func productCategoryHint(productName, features string) string {
- combined := productName + " " + features
- for _, hint := range []string{
- "洗衣精", "洗衣粉", "衣物", "洗碗精", "洗碗機", "碗盤",
- "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳", "洗面奶", "乳液", "精華",
- "防曬", "卸妝", "潔面", "身體乳", "洗手乳", "清潔",
- } {
- if strings.Contains(combined, hint) {
- return hint
- }
- }
- return ""
-}
diff --git a/old/backend/internal/library/knowledge/patrol_phrase.go b/old/backend/internal/library/knowledge/patrol_phrase.go
deleted file mode 100644
index 05c4927..0000000
--- a/old/backend/internal/library/knowledge/patrol_phrase.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "unicode/utf8"
-)
-
-const (
- patrolIntentRelevance = "relevance"
- patrolIntentRecency = "recency"
-)
-
-var patrolTopicAnchors = []string{
- "化療", "乳癌", "荷爾蒙", "標靶", "康復", "癌症", "病友", "敏感", "無香", "抗敏",
- "香料", "香精", "皮膚", "乾癢", "沐浴", "洗髮", "洗面", "卸妝", "防曬", "懷孕",
- "換季", "屏障", "過敏", "搔癢", "紅腫",
-}
-
-var patrolFillers = []string{
- "要", "什麼", "嗎", "怎麼", "請問", "有人", "適合", "用的", "分享", "經驗", "挑選",
- "可以", "不能", "需要", "應該", "到底", "真的", "覺得", "知道", "告訴", "請益",
- "推薦嗎", "好用嗎", "用過", "想", "還是", "會不會", "是不是", "有沒有", "如何", "為什麼",
-}
-
-// PatrolTagFromQuestion keeps research-map questions when already search-shaped.
-func PatrolTagFromQuestion(raw string) string {
- raw = strings.TrimSpace(raw)
- raw = strings.Join(strings.Fields(raw), " ")
- if raw == "" {
- return ""
- }
- runes := utf8.RuneCountInString(raw)
- if runes >= minPatrolTagRunes && runes <= maxPatrolTagRunes {
- if looksLikeThreadsSearch(raw) {
- return raw
- }
- if runes >= 8 && (strings.Contains(raw, " ") || productCategoryHint(raw, "") != "") {
- phrase := ensurePatrolIntent(raw, patrolIntentRelevance)
- if utf8.RuneCountInString(phrase) <= maxPatrolTagRunes && !isMechanicalTag(phrase) {
- return phrase
- }
- }
- }
- return humanizePatrolPhrase(raw, patrolIntentRelevance)
-}
-
-// PatrolTagFromPillar compresses pillar phrases but keeps more context than generic labels.
-func PatrolTagFromPillar(raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- runes := utf8.RuneCountInString(raw)
- if runes >= minPatrolTagRunes && runes <= maxPatrolTagRunes && strings.Contains(raw, " ") {
- return ensurePatrolIntent(raw, patrolIntentRelevance)
- }
- return humanizePatrolPhrase(raw, patrolIntentRelevance)
-}
-
-func humanizePatrolPhrase(raw, intent string) string {
- raw = strings.TrimSpace(raw)
- raw = strings.Join(strings.Fields(raw), " ")
- if raw == "" {
- return ""
- }
-
- runes := utf8.RuneCountInString(raw)
- if runes <= maxPatrolTagRunes && runes >= minPatrolTagRunes && looksLikeThreadsSearch(raw) {
- return raw
- }
-
- phrase := compressPatrolKeywords(raw)
- if phrase == "" {
- phrase = truncateRunes(raw, maxPatrolTagRunes)
- }
- phrase = ensurePatrolIntent(phrase, intent)
- if utf8.RuneCountInString(phrase) > maxPatrolTagRunes {
- phrase = truncateRunes(phrase, maxPatrolTagRunes)
- }
- if utf8.RuneCountInString(phrase) < minPatrolTagRunes {
- return ""
- }
- if isMechanicalTag(phrase) {
- return ""
- }
- return phrase
-}
-
-func looksLikeThreadsSearch(text string) bool {
- if strings.Contains(text, " ") {
- return true
- }
- for _, suffix := range []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助"} {
- if strings.Contains(text, suffix) {
- return true
- }
- }
- return false
-}
-
-func compressPatrolKeywords(text string) string {
- category := productCategoryHint(text, "")
- anchors := []string{}
- seen := map[string]struct{}{}
- for _, anchor := range patrolTopicAnchors {
- if !strings.Contains(text, anchor) {
- continue
- }
- if _, ok := seen[anchor]; ok {
- continue
- }
- seen[anchor] = struct{}{}
- anchors = append(anchors, anchor)
- if len(anchors) >= 2 {
- break
- }
- }
-
- parts := append([]string{}, anchors...)
- if category != "" {
- parts = append(parts, category)
- }
- if len(parts) == 0 {
- for _, chunk := range splitPatrolChunks(text) {
- if isPatrolFiller(chunk) {
- continue
- }
- parts = append(parts, chunk)
- if len(parts) >= 2 {
- break
- }
- }
- }
- if len(parts) == 0 {
- return ""
- }
- phrase := strings.Join(parts, " ")
- if utf8.RuneCountInString(phrase) > maxPatrolTagRunes {
- return truncateRunes(phrase, maxPatrolTagRunes)
- }
- return phrase
-}
-
-func splitPatrolChunks(text string) []string {
- text = strings.TrimSpace(text)
- if text == "" {
- return nil
- }
- if strings.Contains(text, " ") {
- return strings.Fields(text)
- }
- runes := []rune(text)
- if len(runes) <= 6 {
- return []string{text}
- }
- // Long continuous Chinese: take leading topic chunk + trailing product-ish chunk.
- head := string(runes[:minInt(4, len(runes))])
- tail := string(runes[maxInt(0, len(runes)-4):])
- if head == tail {
- return []string{head}
- }
- return []string{head, tail}
-}
-
-// AnchorPatrolTagToProduct injects product category tokens when a tag lacks product context.
-func AnchorPatrolTagToProduct(tag string, in PatrolTagInput) string {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return ""
- }
- hint := productCategoryHint(in.ProductName, in.ProductFeatures)
- if hint == "" {
- for _, matchTag := range in.MatchTags {
- if h := productCategoryHint(matchTag, ""); h != "" {
- hint = h
- break
- }
- }
- }
- if hint == "" {
- return tag
- }
- lowerTag := strings.ToLower(tag)
- if strings.Contains(lowerTag, strings.ToLower(hint)) {
- return tag
- }
- for _, matchTag := range in.MatchTags {
- matchTag = strings.TrimSpace(strings.ToLower(matchTag))
- if len([]rune(matchTag)) >= 2 && strings.Contains(lowerTag, matchTag) {
- return tag
- }
- }
- anchor := hint
- if valueAnchor := primaryPatrolValueAnchor(in); valueAnchor != "" {
- anchor = strings.TrimSpace(valueAnchor + " " + hint)
- }
- anchored := strings.TrimSpace(anchor + " " + tag)
- if utf8.RuneCountInString(anchored) > maxPatrolTagRunes {
- anchored = truncateRunes(anchor+" "+compressPatrolKeywords(tag), maxPatrolTagRunes)
- }
- if utf8.RuneCountInString(anchored) < minPatrolTagRunes {
- return tag
- }
- return anchored
-}
-
-func ensurePatrolIntent(phrase, intent string) string {
- phrase = strings.TrimSpace(phrase)
- if phrase == "" {
- return ""
- }
- if strings.ContainsAny(phrase, "推薦請問怎麼辦好用嗎有人求助") {
- return phrase
- }
- suffix := " 推薦"
- if intent == patrolIntentRecency {
- suffix = " 請問"
- }
- if utf8.RuneCountInString(phrase+suffix) <= maxPatrolTagRunes {
- return phrase + suffix
- }
- return phrase
-}
-
-func isPatrolFiller(chunk string) bool {
- chunk = strings.TrimSpace(chunk)
- if chunk == "" || utf8.RuneCountInString(chunk) < 2 {
- return true
- }
- for _, filler := range patrolFillers {
- if chunk == filler {
- return true
- }
- }
- return false
-}
-
-func truncateRunes(text string, max int) string {
- runes := []rune(strings.TrimSpace(text))
- if len(runes) <= max {
- return string(runes)
- }
- return string(runes[:max])
-}
-
-func maxInt(values ...int) int {
- max := values[0]
- for _, v := range values[1:] {
- if v > max {
- max = v
- }
- }
- return max
-}
diff --git a/old/backend/internal/library/knowledge/patrol_phrase_test.go b/old/backend/internal/library/knowledge/patrol_phrase_test.go
deleted file mode 100644
index e7bf3a1..0000000
--- a/old/backend/internal/library/knowledge/patrol_phrase_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "testing"
-)
-
-func TestPatrolTagFromQuestionPreservesSearchPhrase(t *testing.T) {
- got := PatrolTagFromQuestion("化療後皮膚敏感要換什麼沐浴乳")
- if got != "化療後皮膚敏感要換什麼沐浴乳" {
- t.Fatalf("expected preserved question, got %q", got)
- }
-}
-
-func TestAnchorPatrolTagToProductInjectsCategory(t *testing.T) {
- in := PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精", "衣物清潔"},
- }
- got := AnchorPatrolTagToProduct("敏感肌 推薦", in)
- if got == "敏感肌 推薦" {
- t.Fatal("expected product category to be injected into generic tag")
- }
- if !strings.Contains(got, "洗衣") {
- t.Fatalf("expected laundry anchor in %q", got)
- }
-}
-
-func TestAnchorPatrolTagToProductPrefersValueAnchor(t *testing.T) {
- in := PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏、適合化療族群",
- MatchTags: []string{"無香", "洗衣精"},
- }
- got := AnchorPatrolTagToProduct("皮膚癢 推薦", in)
- if !strings.Contains(got, "無香") {
- t.Fatalf("expected value anchor in %q", got)
- }
- if !strings.Contains(got, "洗衣精") {
- t.Fatalf("expected product form in %q", got)
- }
-}
-
-func TestPatrolTagFromQuestionCompressesGenericLabel(t *testing.T) {
- got := PatrolTagFromQuestion("敏感肌保養")
- if got == "敏感肌保養" {
- t.Fatal("expected compression for generic short label")
- }
- if got == "" {
- t.Fatal("expected non-empty tag")
- }
-}
diff --git a/old/backend/internal/library/knowledge/patrol_rank.go b/old/backend/internal/library/knowledge/patrol_rank.go
deleted file mode 100644
index 7937570..0000000
--- a/old/backend/internal/library/knowledge/patrol_rank.go
+++ /dev/null
@@ -1,285 +0,0 @@
-package knowledge
-
-import (
- "sort"
- "strings"
-)
-
-const MaxTopPatrolTags = 10
-const MaxScanPatrolKeywords = 10
-
-// SelectedPatrolNodes returns nodes flagged for scan.
-func SelectedPatrolNodes(nodes []Node) []Node {
- out := make([]Node, 0, len(nodes))
- for _, node := range nodes {
- if node.SelectedForScan {
- out = append(out, node)
- }
- }
- return out
-}
-
-// NodesForPatrolKeywordDerivation prefers user-selected graph nodes when present.
-func NodesForPatrolKeywordDerivation(nodes []Node) []Node {
- if selected := SelectedPatrolNodes(nodes); len(selected) > 0 {
- return selected
- }
- return nodes
-}
-
-type PatrolTagCandidate struct {
- Tag string
- Score int
- Reason string
- Intent string
-}
-
-func CollectPatrolTagsFromGraph(in PatrolTagInput, nodes []Node) []string {
- return SelectTopPatrolTags(BuildPatrolCandidates(in, nodes), MaxTopPatrolTags)
-}
-
-func BuildPatrolCandidates(in PatrolTagInput, nodes []Node) []PatrolTagCandidate {
- out := []PatrolTagCandidate{}
- for i, tag := range SanitizePatrolKeywordList(in.PatrolKeywords) {
- addPatrolCandidate(&out, tag, 120-i, "手動精選", patrolIntentRelevance)
- }
- for i, q := range in.Questions {
- addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+12, "受眾提問", patrolIntentRelevance)
- }
- for i, pillar := range in.Pillars {
- addPatrolCandidate(&out, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+6, "內容支柱", patrolIntentRelevance)
- }
- for i, matchTag := range in.MatchTags {
- addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
- }
- if in.ProductName != "" {
- addPatrolCandidate(&out, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
- }
- nodesToUse := NodesForPatrolKeywordDerivation(nodes)
- for _, node := range nodesToUse {
- if len(SelectedPatrolNodes(nodes)) == 0 && !IsPainNode(node) && node.Layer > 1 {
- continue
- }
- nodeScore := scoreNode(node)
- for _, tag := range node.DerivedTags.Relevance {
- addPatrolCandidate(&out, tag, nodeScore, nodePatrolReason(node), patrolIntentRelevance)
- }
- for _, tag := range node.DerivedTags.Recency {
- addPatrolCandidate(&out, tag, nodeScore-3, nodePatrolReason(node), patrolIntentRecency)
- }
- }
- return out
-}
-
-func SelectTopPatrolTags(candidates []PatrolTagCandidate, limit int) []string {
- if limit <= 0 {
- return nil
- }
- picked := selectTopPatrolCandidates(candidates, limit)
- out := make([]string, 0, len(picked))
- for _, item := range picked {
- out = append(out, item.Tag)
- }
- return out
-}
-
-func selectBestDerivedTags(candidates []PatrolTagCandidate, relLimit, recLimit int) DerivedTags {
- sort.SliceStable(candidates, func(i, j int) bool {
- if candidates[i].Score == candidates[j].Score {
- return candidates[i].Tag < candidates[j].Tag
- }
- return candidates[i].Score > candidates[j].Score
- })
- seenRelKey := map[string]struct{}{}
- seenRecTag := map[string]struct{}{}
- relevance := []string{}
- recency := []string{}
- for _, item := range candidates {
- if item.Intent == patrolIntentRecency {
- continue
- }
- key := patrolTagDedupeKey(item.Tag)
- if _, ok := seenRelKey[key]; ok {
- continue
- }
- seenRelKey[key] = struct{}{}
- relevance = append(relevance, item.Tag)
- if len(relevance) >= relLimit {
- break
- }
- }
- for _, item := range candidates {
- if item.Intent != patrolIntentRecency {
- continue
- }
- if _, ok := seenRecTag[item.Tag]; ok {
- continue
- }
- seenRecTag[item.Tag] = struct{}{}
- recency = append(recency, item.Tag)
- if len(recency) >= recLimit {
- break
- }
- }
- if len(recency) < recLimit && len(relevance) > 0 {
- for _, rel := range relevance {
- alt := patrolRecencyFallback(rel)
- if alt == "" || containsString(relevance, alt) {
- continue
- }
- if _, ok := seenRecTag[alt]; ok {
- continue
- }
- recency = append(recency, alt)
- if len(recency) >= recLimit {
- break
- }
- }
- }
- return DerivedTags{
- Relevance: capTags(uniqueTags(relevance), relLimit),
- Recency: capTags(uniqueTags(recency), recLimit),
- }
-}
-
-func selectTopPatrolCandidates(candidates []PatrolTagCandidate, limit int) []PatrolTagCandidate {
- if limit <= 0 || len(candidates) == 0 {
- return nil
- }
- sort.SliceStable(candidates, func(i, j int) bool {
- if candidates[i].Score == candidates[j].Score {
- return candidates[i].Tag < candidates[j].Tag
- }
- return candidates[i].Score > candidates[j].Score
- })
- seenTag := map[string]struct{}{}
- seenKey := map[string]struct{}{}
- out := make([]PatrolTagCandidate, 0, limit)
- for _, item := range candidates {
- tag := strings.TrimSpace(item.Tag)
- if tag == "" {
- continue
- }
- key := patrolTagDedupeKey(tag)
- if _, ok := seenTag[tag]; ok {
- continue
- }
- if _, ok := seenKey[key]; ok {
- continue
- }
- seenTag[tag] = struct{}{}
- seenKey[key] = struct{}{}
- out = append(out, PatrolTagCandidate{
- Tag: tag,
- Score: item.Score,
- Reason: item.Reason,
- Intent: item.Intent,
- })
- if len(out) >= limit {
- break
- }
- }
- return out
-}
-
-func addPatrolCandidate(out *[]PatrolTagCandidate, tag string, score int, reason, intent string) {
- tag = strings.TrimSpace(tag)
- if tag == "" || score <= 0 {
- return
- }
- *out = append(*out, PatrolTagCandidate{
- Tag: tag,
- Score: score,
- Reason: reason,
- Intent: intent,
- })
-}
-
-func scoreQuestion(index int) int {
- score := 100 - index*4
- if score < 70 {
- return 70
- }
- return score
-}
-
-func scorePillar(index int) int {
- score := 74 - index*3
- if score < 55 {
- return 55
- }
- return score
-}
-
-func scoreMatchTag(index int) int {
- score := 90 - index*4
- if score < 70 {
- return 70
- }
- return score
-}
-
-func scoreProduct() int {
- return 58
-}
-
-func scoreNode(node Node) int {
- base := 48
- switch node.Layer {
- case 0:
- base = 82
- case 1:
- base = 68
- case 2:
- base = 52
- }
- if IsPainNode(node) {
- base += 8
- }
- if node.ProductFitScore > 0 {
- base += node.ProductFitScore / 5
- }
- return base
-}
-
-func nodePatrolReason(node Node) string {
- switch node.Layer {
- case 0:
- return "核心痛點"
- case 1:
- return "高相關延伸"
- default:
- return "周邊情境"
- }
-}
-
-func patrolTagDedupeKey(tag string) string {
- tag = strings.TrimSpace(tag)
- for _, suffix := range []string{" 推薦", " 請問", " 怎麼辦", " 好用嗎", " 有人用過嗎", " 有推薦嗎"} {
- if strings.HasSuffix(tag, suffix) {
- tag = strings.TrimSuffix(tag, suffix)
- break
- }
- }
- return tag
-}
-
-func patrolRecencyFallback(relevanceTag string) string {
- alt := patrolTagFromSource(relevanceTag, patrolIntentRecency)
- if alt != "" && alt != relevanceTag {
- return alt
- }
- if strings.Contains(relevanceTag, "請問") {
- return ""
- }
- return patrolTagFromSource(relevanceTag+" 請問", patrolIntentRecency)
-}
-
-func containsString(items []string, target string) bool {
- for _, item := range items {
- if item == target {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/knowledge/patrol_rank_test.go b/old/backend/internal/library/knowledge/patrol_rank_test.go
deleted file mode 100644
index adfa421..0000000
--- a/old/backend/internal/library/knowledge/patrol_rank_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestSelectTopPatrolTagsLimitsAndDedupes(t *testing.T) {
- tags := SelectTopPatrolTags([]PatrolTagCandidate{
- {Tag: "化療 沐浴乳", Score: 100, Reason: "受眾提問", Intent: patrolIntentRelevance},
- {Tag: "化療 沐浴乳 推薦", Score: 98, Reason: "受眾提問", Intent: patrolIntentRelevance},
- {Tag: "無香沐浴乳 推薦", Score: 90, Reason: "產品匹配", Intent: patrolIntentRelevance},
- {Tag: "乳癌 沐浴乳", Score: 88, Reason: "內容支柱", Intent: patrolIntentRelevance},
- {Tag: "荷爾蒙 敏感", Score: 80, Reason: "內容支柱", Intent: patrolIntentRelevance},
- {Tag: "化療 皮膚", Score: 75, Reason: "高相關延伸", Intent: patrolIntentRelevance},
- {Tag: "病友 沐浴乳", Score: 70, Reason: "周邊情境", Intent: patrolIntentRelevance},
- {Tag: "抗敏 沐浴乳", Score: 65, Reason: "置入產品", Intent: patrolIntentRelevance},
- {Tag: "香精 過敏", Score: 60, Reason: "周邊情境", Intent: patrolIntentRelevance},
- {Tag: "標靶 治療 沐浴", Score: 58, Reason: "周邊情境", Intent: patrolIntentRelevance},
- {Tag: "無香 沐浴乳 請問", Score: 56, Reason: "受眾提問", Intent: patrolIntentRelevance},
- }, MaxTopPatrolTags)
- if len(tags) > MaxTopPatrolTags {
- t.Fatalf("expected at most %d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
- }
- if len(tags) < 8 {
- t.Fatalf("expected at least 8 deduped tags, got %d: %v", len(tags), tags)
- }
- if tags[0] != "化療 沐浴乳" {
- t.Fatalf("expected highest score first, got %q", tags[0])
- }
-}
-
-func TestCollectPatrolTagsCapsOutput(t *testing.T) {
- tags := CollectPatrolTags(PatrolTagInput{
- ProductName: "抗敏無香沐浴露",
- MatchTags: []string{"化療", "無香沐浴乳", "乳癌"},
- Questions: []string{
- "化療後皮膚敏感要換什麼沐浴乳",
- "乳癌治療中不能用有香味的沐浴乳嗎",
- "癌症病人適合用的無香沐浴乳推薦",
- "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳",
- },
- Pillars: []string{
- "化療皮膚敏感無香沐浴乳",
- "乳癌病友沐浴用品挑選",
- "荷爾蒙治療對香味敏感",
- "癌症康復後換清潔品牌",
- },
- })
- if len(tags) > MaxTopPatrolTags {
- t.Fatalf("expected <=%d tags, got %d: %v", MaxTopPatrolTags, len(tags), tags)
- }
-}
diff --git a/old/backend/internal/library/knowledge/patrol_resolve.go b/old/backend/internal/library/knowledge/patrol_resolve.go
deleted file mode 100644
index bdac43d..0000000
--- a/old/backend/internal/library/knowledge/patrol_resolve.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package knowledge
-
-// ResolveScanPatrolKeywords picks the most relevant search phrases for the Search API.
-// Research map and graph nodes are inputs; UI display order is not preserved.
-func ResolveScanPatrolKeywords(explicit, saved []string, input PatrolTagInput, nodes []Node) []string {
- nodes = NodesForPatrolKeywordDerivation(nodes)
- return SelectBestSearchKeywords(explicit, saved, input, nodes, MaxScanPatrolKeywords)
-}
-
-func capPatrolKeywordList(keywords []string, limit int) []string {
- keywords = NormalizePatrolKeywordList(keywords)
- if limit <= 0 || len(keywords) <= limit {
- return keywords
- }
- return keywords[:limit]
-}
diff --git a/old/backend/internal/library/knowledge/patrol_resolve_test.go b/old/backend/internal/library/knowledge/patrol_resolve_test.go
deleted file mode 100644
index 05af32f..0000000
--- a/old/backend/internal/library/knowledge/patrol_resolve_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestResolveScanPatrolKeywordsIncludesExplicitHint(t *testing.T) {
- got := ResolveScanPatrolKeywords(
- []string{"手動 關鍵字"},
- []string{"已儲存"},
- PatrolTagInput{Questions: []string{"敏感肌 怎麼辦"}},
- nil,
- )
- if len(got) == 0 {
- t.Fatal("expected ranked keywords")
- }
- found := false
- for _, kw := range got {
- if kw == "手動 關鍵字" {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("ResolveScanPatrolKeywords() = %#v, want explicit hint included", got)
- }
-}
-
-func TestResolveScanPatrolKeywordsFromResearchMapWithoutGraph(t *testing.T) {
- got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{
- Questions: []string{"化療後 皮膚乾 怎麼辦"},
- }, nil)
- if len(got) == 0 {
- t.Fatal("expected research map questions to produce patrol keywords without graph nodes")
- }
-}
-
-func TestResolveScanPatrolKeywordsPrefersSelectedNodes(t *testing.T) {
- nodes := []Node{
- {ID: "pain", Label: "化療後 皮膚乾", SelectedForScan: true, ProductFitScore: 88, NodeKind: "pain", DerivedTags: DerivedTags{Relevance: []string{"化療後 皮膚乾 沐浴乳"}}},
- {ID: "skip", Label: "無關節點", SelectedForScan: false, ProductFitScore: 90, DerivedTags: DerivedTags{Relevance: []string{"不該出現"}}},
- }
- got := ResolveScanPatrolKeywords(nil, nil, PatrolTagInput{}, nodes)
- if len(got) == 0 {
- t.Fatal("expected selected node to produce patrol keywords")
- }
- found := false
- for _, kw := range got {
- if kw == "化療後 皮膚乾 沐浴乳" || kw == "化療後 皮膚乾" {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("expected selected node keyword, got %#v", got)
- }
- for _, kw := range got {
- if kw == "不該出現" {
- t.Fatalf("unselected node leaked into keywords: %#v", got)
- }
- }
-}
diff --git a/old/backend/internal/library/knowledge/patrol_search.go b/old/backend/internal/library/knowledge/patrol_search.go
deleted file mode 100644
index aa08671..0000000
--- a/old/backend/internal/library/knowledge/patrol_search.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package knowledge
-
-import (
- "sort"
- "strings"
-)
-
-// SelectBestSearchKeywords pools research map, graph, product, and optional hints,
-// then returns the most intent-relevant phrases for Search API (not UI display order).
-func SelectBestSearchKeywords(explicit, saved []string, in PatrolTagInput, nodes []Node, limit int) []string {
- if limit <= 0 {
- limit = MaxScanPatrolKeywords
- }
- profile := BuildIntentProfile(in)
- candidates := BuildPatrolCandidates(in, nodes)
-
- for i, tag := range NormalizePatrolKeywordList(explicit) {
- addPatrolCandidate(&candidates, tag, 156-i, "掃描指定", patrolIntentRelevance)
- }
- for i, tag := range NormalizePatrolKeywordList(saved) {
- if containsNormalizedTag(explicit, tag) {
- continue
- }
- addPatrolCandidate(&candidates, tag, 88-i, "研究地圖儲存", patrolIntentRelevance)
- }
-
- type ranked struct {
- tag string
- score int
- }
- scored := make([]ranked, 0, len(candidates))
- seen := map[string]struct{}{}
- for _, item := range selectTopPatrolCandidates(candidates, len(candidates)+32) {
- tag := strings.TrimSpace(item.Tag)
- if tag == "" {
- continue
- }
- key := patrolTagDedupeKey(tag)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- if IsTangentialToIntent(tag, profile) {
- continue
- }
- intent := ScoreIntentSimilarity(tag, profile)
- if intent < 8 && item.Score < 65 {
- continue
- }
- final := (item.Score*2 + intent*4) / 6
- if final <= 0 {
- continue
- }
- scored = append(scored, ranked{tag: tag, score: final})
- }
-
- sort.SliceStable(scored, func(i, j int) bool {
- if scored[i].score == scored[j].score {
- return scored[i].tag < scored[j].tag
- }
- return scored[i].score > scored[j].score
- })
-
- out := make([]string, 0, limit)
- for _, item := range scored {
- out = append(out, item.tag)
- if len(out) >= limit {
- break
- }
- }
- return out
-}
-
-func containsNormalizedTag(items []string, target string) bool {
- targetKey := patrolTagDedupeKey(target)
- for _, item := range items {
- if patrolTagDedupeKey(item) == targetKey {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/knowledge/patrol_search_test.go b/old/backend/internal/library/knowledge/patrol_search_test.go
deleted file mode 100644
index c5738ae..0000000
--- a/old/backend/internal/library/knowledge/patrol_search_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "testing"
-)
-
-func TestSelectBestSearchKeywordsRanksByIntentNotSavedOrder(t *testing.T) {
- in := PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏、適合化療族群",
- MatchTags: []string{"無香", "抗敏", "洗衣精"},
- Questions: []string{"化療後想找不會刺激的洗衣精"},
- Pillars: []string{"無香洗衣", "化療後衣物清潔"},
- }
- got := SelectBestSearchKeywords(
- nil,
- []string{"洗衣機 推薦", "無香 洗衣精 請問"},
- in,
- nil,
- 4,
- )
- if len(got) == 0 {
- t.Fatal("expected ranked keywords")
- }
- for _, kw := range got {
- if strings.Contains(kw, "洗衣機") {
- t.Fatalf("expected weak saved keyword to lose ranking, got %#v", got)
- }
- }
- found := false
- for _, kw := range got {
- if strings.Contains(kw, "無香") || strings.Contains(kw, "化療") {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("expected intent-aligned keyword in %#v", got)
- }
-}
-
-func TestSelectBestSearchKeywordsIncludesExplicitHint(t *testing.T) {
- in := PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- MatchTags: []string{"無香", "洗衣精"},
- }
- got := SelectBestSearchKeywords(
- []string{"化療 無香 洗衣精"},
- []string{"洗衣機 推薦"},
- in,
- nil,
- 3,
- )
- if len(got) == 0 || got[0] != "化療 無香 洗衣精" {
- t.Fatalf("expected explicit hint to rank first, got %#v", got)
- }
-}
diff --git a/old/backend/internal/library/knowledge/patrol_tags.go b/old/backend/internal/library/knowledge/patrol_tags.go
deleted file mode 100644
index bee71c1..0000000
--- a/old/backend/internal/library/knowledge/patrol_tags.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "unicode/utf8"
-)
-
-const (
- minPatrolTagRunes = 4
- maxPatrolTagRunes = 16
- maxRelevanceTags = 1
- maxRecencyTags = 1
-)
-
-func DeriveSearchTagsFromGraph(graph *Graph, in PatrolTagInput) {
- if graph == nil {
- return
- }
- for i := range graph.Nodes {
- graph.Nodes[i].DerivedTags = derivePatrolTagsForNode(graph.Nodes[i], in)
- }
- graph.PainTagCount = CountPainTagCandidates(graph.Nodes)
-}
-
-// CollectPatrolTags returns精選海巡搜尋句(研究地圖 + 產品 + 高相關節點)。
-func CollectPatrolTags(in PatrolTagInput) []string {
- return CollectPatrolTagsFromGraph(in, nil)
-}
-
-// DerivePatrolTagsForNode resolves a single node's patrol tags.
-func DerivePatrolTagsForNode(node Node, in PatrolTagInput) DerivedTags {
- return derivePatrolTagsForNode(node, in)
-}
-
-func derivePatrolTagsForNode(node Node, in PatrolTagInput) DerivedTags {
- presetRel := sanitizePatrolTags(node.PatrolRelevance)
- presetRec := sanitizePatrolTags(node.PatrolRecency)
- if len(presetRel) > 0 || len(presetRec) > 0 {
- return DerivedTags{
- Relevance: capTags(presetRel, maxRelevanceTags),
- Recency: capTags(presetRec, maxRecencyTags),
- }
- }
- if in.HasResearchMap() || in.HasProductContext() {
- derived := deriveFromResearchMapAndProduct(node, in)
- if len(derived.Relevance) > 0 || len(derived.Recency) > 0 {
- return derived
- }
- }
- return deriveFallbackPatrolTags(node)
-}
-
-func deriveFromResearchMapAndProduct(node Node, in PatrolTagInput) DerivedTags {
- candidates := []PatrolTagCandidate{}
- matches := isCoreNode(node)
-
- for i, q := range in.Questions {
- if matches || nodeMatchesResearchText(node, q, in) {
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+10, "受眾提問", patrolIntentRelevance)
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(q, patrolIntentRecency), in), scoreQuestion(i)-2, "受眾提問", patrolIntentRecency)
- }
- }
- for i, pillar := range in.Pillars {
- if matches || nodeMatchesResearchText(node, pillar, in) {
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromPillar(pillar), in), scorePillar(i)+4, "內容支柱", patrolIntentRelevance)
- }
- }
- for i, matchTag := range in.MatchTags {
- if matches || nodeMatchesResearchText(node, matchTag, in) {
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
- }
- }
- if in.ProductName != "" && (matches || nodeMatchesResearchText(node, in.ProductName, in)) {
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
- }
-
- if len(candidates) == 0 && IsPainNode(node) {
- for i, q := range in.Questions {
- if i >= 2 {
- break
- }
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(PatrolTagFromQuestion(q), in), scoreQuestion(i)+8, "受眾提問", patrolIntentRelevance)
- }
- }
- if len(candidates) == 0 && IsPainNode(node) {
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(in.ProductName, patrolIntentRelevance), in), scoreProduct(), "置入產品", patrolIntentRelevance)
- for i, matchTag := range in.MatchTags {
- if i >= 1 {
- break
- }
- addPatrolCandidate(&candidates, AnchorPatrolTagToProduct(patrolTagFromSource(matchTag, patrolIntentRelevance), in), scoreMatchTag(i), "產品匹配", patrolIntentRelevance)
- }
- }
-
- return selectBestDerivedTags(candidates, maxRelevanceTags, maxRecencyTags)
-}
-
-func patrolTagFromSource(raw, intent string) string {
- tag := humanizePatrolPhrase(raw, intent)
- if tag == "" || isMechanicalTag(tag) {
- return ""
- }
- return tag
-}
-
-func isCoreNode(node Node) bool {
- return node.Layer == 0 || strings.EqualFold(strings.TrimSpace(node.Type), "core")
-}
-
-func nodeMatchesResearchText(node Node, text string, in PatrolTagInput) bool {
- label := strings.TrimSpace(node.Label)
- text = strings.TrimSpace(text)
- if label == "" || text == "" {
- return false
- }
- if label == text || strings.Contains(text, label) || strings.Contains(label, text) {
- return true
- }
- if sharesMeaningfulSubstring(label, text) {
- return true
- }
- profile := BuildIntentProfile(in)
- joined := strings.TrimSpace(NodeIntentText(node) + " " + text)
- return ScoreIntentSimilarity(joined, profile) >= 40
-}
-
-func deriveFallbackPatrolTags(node Node) DerivedTags {
- label := strings.TrimSpace(node.Label)
- if label == "" || !IsPainNode(node) {
- return DerivedTags{}
- }
- relevance := []string{patrolTagFromSource(label, patrolIntentRelevance)}
- recency := []string{patrolTagFromSource(label, patrolIntentRecency)}
- return DerivedTags{
- Relevance: capTags(uniqueTags(relevance), maxRelevanceTags),
- Recency: capTags(uniqueTags(recency), maxRecencyTags),
- }
-}
-
-func capTags(items []string, max int) []string {
- if max <= 0 || len(items) <= max {
- return items
- }
- return items[:max]
-}
-
-func sharesMeaningfulSubstring(a, b string) bool {
- ar := []rune(a)
- br := []rune(b)
- if len(ar) < 2 || len(br) < 2 {
- return false
- }
- for size := minInt(len(ar), len(br), 4); size >= 2; size-- {
- for i := 0; i+size <= len(ar); i++ {
- chunk := string(ar[i : i+size])
- if strings.Contains(b, chunk) {
- return true
- }
- }
- }
- return false
-}
-
-func minInt(values ...int) int {
- if len(values) == 0 {
- return 0
- }
- min := values[0]
- for _, v := range values[1:] {
- if v < min {
- min = v
- }
- }
- return min
-}
-
-// NormalizePatrolKeywordList keeps user-edited patrol keywords as typed (trim + dedupe only).
-func NormalizePatrolKeywordList(items []string) []string {
- out := make([]string, 0, len(items))
- seen := make(map[string]struct{}, len(items))
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- runes := utf8.RuneCountInString(item)
- if runes < 2 || runes > maxPatrolTagRunes {
- continue
- }
- if _, ok := seen[item]; ok {
- continue
- }
- seen[item] = struct{}{}
- out = append(out, item)
- if len(out) >= MaxTopPatrolTags {
- break
- }
- }
- return out
-}
-
-// SanitizePatrolKeywordList normalizes auto-derived海巡 tag(圖譜 / AI 產生)。
-func SanitizePatrolKeywordList(items []string) []string {
- out := sanitizePatrolTags(items)
- if len(out) > MaxTopPatrolTags {
- return out[:MaxTopPatrolTags]
- }
- return out
-}
-
-func sanitizePatrolTags(items []string) []string {
- out := make([]string, 0, len(items))
- for _, item := range items {
- if tag := sanitizePatrolTag(item); tag != "" {
- out = append(out, tag)
- }
- }
- return uniqueTags(out)
-}
-
-func sanitizePatrolTag(text string) string {
- text = humanizePatrolPhrase(text, patrolIntentRelevance)
- if text == "" {
- return ""
- }
- if isMechanicalTag(text) {
- return ""
- }
- return text
-}
-
-func isMechanicalTag(text string) bool {
- if strings.Contains(text, " ") {
- return false
- }
- return utf8.RuneCountInString(text) <= 4
-}
-
-func uniqueTags(items []string) []string {
- seen := map[string]struct{}{}
- out := make([]string, 0, len(items))
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- if _, ok := seen[item]; ok {
- continue
- }
- seen[item] = struct{}{}
- out = append(out, item)
- }
- return out
-}
diff --git a/old/backend/internal/library/knowledge/patrol_tags_test.go b/old/backend/internal/library/knowledge/patrol_tags_test.go
deleted file mode 100644
index 94b1a69..0000000
--- a/old/backend/internal/library/knowledge/patrol_tags_test.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestNormalizePatrolKeywordListPreservesUserInput(t *testing.T) {
- got := NormalizePatrolKeywordList([]string{" 化療 沐浴乳 ", "化療 沐浴乳", "敏感肌"})
- if len(got) != 2 {
- t.Fatalf("expected 2 keywords, got %v", got)
- }
- if got[0] != "化療 沐浴乳" || got[1] != "敏感肌" {
- t.Fatalf("unexpected normalize result: %v", got)
- }
-}
diff --git a/old/backend/internal/library/knowledge/quality.go b/old/backend/internal/library/knowledge/quality.go
deleted file mode 100644
index e028a82..0000000
--- a/old/backend/internal/library/knowledge/quality.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package knowledge
-
-import (
- "strconv"
- "strings"
- "unicode/utf8"
-)
-
-const (
- minGraphNodes = 18
- minLayer1Nodes = 6
- minLayer2Nodes = 10
- minBreadthNodes = 22
- targetGraphNodes = 28
- minNodeCopyRunes = 15
-)
-
-func GraphTooThin(g Graph) bool {
- if len(g.Nodes) < minGraphNodes {
- return true
- }
- layerCounts := map[int]int{}
- thinCopy := 0
- for _, node := range g.Nodes {
- layerCounts[node.Layer]++
- if utf8.RuneCountInString(strings.TrimSpace(node.Relation)) < minNodeCopyRunes ||
- utf8.RuneCountInString(strings.TrimSpace(node.PlacementValue)) < minNodeCopyRunes {
- thinCopy++
- }
- }
- if layerCounts[1] < minLayer1Nodes || layerCounts[2] < minLayer2Nodes {
- return true
- }
- if thinCopy > len(g.Nodes)/2 {
- return true
- }
- return false
-}
-
-func GraphNeedsBreadth(g Graph) bool {
- return len(g.Nodes) < minBreadthNodes
-}
-
-func MinBreadthGraphNodes() int {
- return minBreadthNodes
-}
-
-func KnowledgeGraphJSONOnlyRetryPrompt() string {
- return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
-- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
-- 結構需含 nodes 與 edges 陣列,欄位齊全
-- 不要使用 ... 省略,也不要加註解或尾端逗號`)
-}
-
-func KnowledgeGraphRetryUserPrompt() string {
- return strings.TrimSpace(`上次產出過於簡略或節點不足。請重新產出完整 TKG JSON:
-- 節點總數 **24~32 個**,L1≥8、L2≥12(廣度優先,多方向觸及)
-- 每個節點的 relation 與 placementValue 各 25~55 字,寫完整句子
-- 痛點/求助類節點至少 10 個;L2 周邊情境要覆蓋多種生活場景,不可精簡或省略欄位`)
-}
-
-func KnowledgeGraphBreadthUserPrompt(currentNodes int) string {
- return strings.TrimSpace(`目前延伸知識僅 ` + strconv.Itoa(currentNodes) + ` 個節點,廣度不足。請**維持既有節點**,只追加新節點與邊:
-- 至少再增加 **10~14 個**節點,總數目標 **24~32 個**
-- 優先補齊 L2 周邊情境:不同治療階段、生活事件、相鄰困擾、相關品類與使用場景
-- 也要補 L1 直接相關的成因、症狀、機制,讓圖譜能觸及更多討論方向
-- 新節點的 relation 與 placementValue 必須各 25~55 字;不要 high/medium/low
-- 只追加,不要刪除或覆蓋既有節點`)
-}
diff --git a/old/backend/internal/library/knowledge/quality_test.go b/old/backend/internal/library/knowledge/quality_test.go
deleted file mode 100644
index 28f82fb..0000000
--- a/old/backend/internal/library/knowledge/quality_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package knowledge
-
-import (
- "fmt"
- "testing"
-)
-
-func TestGraphTooThin(t *testing.T) {
- thin := Graph{
- Nodes: []Node{
- {Label: "a", Layer: 0, Relation: "短", PlacementValue: "短"},
- },
- }
- if !GraphTooThin(thin) {
- t.Fatal("expected thin graph")
- }
-
- nodes := []Node{{Label: "core", Layer: 0, Relation: "核心種子主題的完整說明文字", PlacementValue: "核心痛點帖最常求推薦適合自然回覆"}}
- for i := 0; i < 7; i++ {
- nodes = append(nodes, Node{
- Label: fmt.Sprintf("l1-%d", i),
- Layer: 1,
- NodeKind: "pain",
- Relation: "這是 L1 節點與種子詞之間的完整脈絡說明",
- PlacementValue: "這類討論串常求產品推薦適合以使用經驗自然帶入",
- })
- }
- for i := 0; i < 11; i++ {
- nodes = append(nodes, Node{
- Label: fmt.Sprintf("l2-%d", i),
- Layer: 2,
- NodeKind: "symptom",
- Relation: "這是 L2 周邊情境與種子詞之間的完整脈絡說明",
- PlacementValue: "周邊情境帖仍有置入空間可分享溫和修護經驗",
- })
- }
- if GraphTooThin(Graph{Nodes: nodes}) {
- t.Fatal("expected rich graph")
- }
-}
diff --git a/old/backend/internal/library/knowledge/queries.go b/old/backend/internal/library/knowledge/queries.go
deleted file mode 100644
index d815079..0000000
--- a/old/backend/internal/library/knowledge/queries.go
+++ /dev/null
@@ -1,414 +0,0 @@
-package knowledge
-
-import (
- "encoding/json"
- "fmt"
- "strings"
- "sync"
-
- libprompt "haixun-backend/internal/library/prompt"
-)
-
-type queryConfig struct {
- MaxPlanQueries int `json:"max_plan_queries"`
- HybridMaxPlanQueries int `json:"hybrid_max_plan_queries"`
- MaxSupplemental int `json:"max_supplemental_queries"`
- HybridMaxSupplemental int `json:"hybrid_max_supplemental_queries"`
- ResultsPerQuery int `json:"results_per_query"`
- MinSourcesBeforeStop int `json:"min_sources_before_stop"`
- MaxSourcesCap int `json:"max_sources_cap"`
- BraveCollectConcurrency int `json:"brave_collect_concurrency"`
- MaxPatrolKeywordQueries int `json:"max_patrol_keyword_queries"`
- MaxQuestionQueries int `json:"max_question_queries"`
- MaxPillarQueries int `json:"max_pillar_queries"`
- MaxPlanBaseQueries int `json:"max_plan_base_queries"`
- MaxPeripheralQueries int `json:"max_peripheral_queries"`
- MaxL1Labels int `json:"max_l1_labels"`
- MinPainTagCandidates int `json:"min_pain_tag_candidates"`
- MinTotalTagCandidates int `json:"min_total_tag_candidates"`
- PlanBase []string `json:"plan_base"`
- PlanPeripheral []string `json:"plan_peripheral"`
- PlanAudience string `json:"plan_audience"`
- PlanL1Cause string `json:"plan_l1_cause"`
- PlanL1Pain string `json:"plan_l1_pain"`
- PlanPillar string `json:"plan_pillar"`
- PlanQuestion string `json:"plan_question"`
- Supplemental []string `json:"supplemental"`
- SupplementalL1 string `json:"supplemental_l1"`
- SupplementalPillar string `json:"supplemental_pillar"`
- RecencySuffix string `json:"recency_suffix"`
- RecencyHelpMarkers string `json:"recency_help_markers"`
-}
-
-var (
- queryCfgOnce sync.Once
- queryCfg queryConfig
- queryCfgErr error
-)
-
-func loadQueryConfig() (queryConfig, error) {
- queryCfgOnce.Do(func() {
- raw, err := libprompt.KnowledgeGraphQueryConfig()
- if err != nil {
- queryCfgErr = err
- return
- }
- payload, err := json.Marshal(raw)
- if err != nil {
- queryCfgErr = err
- return
- }
- queryCfgErr = json.Unmarshal(payload, &queryCfg)
- })
- return queryCfg, queryCfgErr
-}
-
-func MaxPlanQueriesPerRound() int {
- cfg, err := loadQueryConfig()
- if err != nil || cfg.MaxPlanQueries <= 0 {
- return 15
- }
- return cfg.MaxPlanQueries
-}
-
-func MaxSupplementalQueries() int {
- cfg, err := loadQueryConfig()
- if err != nil || cfg.MaxSupplemental <= 0 {
- return 5
- }
- return cfg.MaxSupplemental
-}
-
-func MinPainTagCandidates() int {
- cfg, err := loadQueryConfig()
- if err != nil || cfg.MinPainTagCandidates <= 0 {
- return 8
- }
- return cfg.MinPainTagCandidates
-}
-
-type PlanInput struct {
- Seed string
- TargetAudience string
- ProductBrief string
- Pillars []string
- Questions []string
- PatrolKeywords []string
- L1Labels []string
- Supplemental bool
- Strategy ExpandStrategy
-}
-
-func PlanQueries(in PlanInput) []string {
- cfg, err := loadQueryConfig()
- if err != nil {
- return nil
- }
- seed := strings.TrimSpace(in.Seed)
- if seed == "" {
- return nil
- }
- if in.Supplemental {
- return supplementalQueries(cfg, in)
- }
- return planPrimaryQueries(cfg, in)
-}
-
-func queryBudget(cfg queryConfig, strategy ExpandStrategy, supplemental bool) int {
- if supplemental {
- if strategy == ExpandStrategyHybrid {
- if cfg.HybridMaxSupplemental > 0 {
- return cfg.HybridMaxSupplemental
- }
- return 0
- }
- max := cfg.MaxSupplemental
- if max <= 0 {
- return 4
- }
- return max
- }
- if strategy == ExpandStrategyHybrid {
- max := cfg.HybridMaxPlanQueries
- if max <= 0 {
- return 5
- }
- return max
- }
- max := cfg.MaxPlanQueries
- if max <= 0 {
- return 10
- }
- return max
-}
-
-func planPrimaryQueries(cfg queryConfig, in PlanInput) []string {
- seed := strings.TrimSpace(in.Seed)
- budget := queryBudget(cfg, in.Strategy, false)
- if budget <= 0 {
- return nil
- }
- seen := map[string]struct{}{}
- out := make([]string, 0, budget)
- add := func(q string) bool {
- q = strings.TrimSpace(q)
- if q == "" {
- return false
- }
- if _, ok := seen[q]; ok {
- return false
- }
- seen[q] = struct{}{}
- out = append(out, q)
- return len(out) >= budget
- }
-
- vars := map[string]string{"seed": seed, "audience": strings.TrimSpace(in.TargetAudience)}
-
- patrolLimit := cfg.MaxPatrolKeywordQueries
- if patrolLimit <= 0 {
- patrolLimit = 4
- }
- for i, keyword := range in.PatrolKeywords {
- if i >= patrolLimit {
- break
- }
- if add(keyword) {
- return out
- }
- }
-
- questionLimit := cfg.MaxQuestionQueries
- if questionLimit <= 0 {
- questionLimit = 3
- }
- for i, question := range in.Questions {
- if i >= questionLimit {
- break
- }
- question = strings.TrimSpace(question)
- if question == "" {
- continue
- }
- if tpl := strings.TrimSpace(cfg.PlanQuestion); tpl != "" {
- if add(renderQueryTemplate(tpl, map[string]string{"question": question})) {
- return out
- }
- } else if add(question) {
- return out
- }
- }
-
- pillarLimit := cfg.MaxPillarQueries
- if pillarLimit <= 0 {
- pillarLimit = 2
- }
- for i, pillar := range in.Pillars {
- if i >= pillarLimit {
- break
- }
- pillar = strings.TrimSpace(pillar)
- if pillar == "" {
- continue
- }
- if tpl := strings.TrimSpace(cfg.PlanPillar); tpl != "" {
- if add(renderQueryTemplate(tpl, map[string]string{"pillar": pillar})) {
- return out
- }
- } else if add(pillar + " 請問") {
- return out
- }
- }
-
- baseLimit := cfg.MaxPlanBaseQueries
- if baseLimit <= 0 {
- baseLimit = 3
- }
- for i, tpl := range cfg.PlanBase {
- if i >= baseLimit {
- break
- }
- if add(renderQueryTemplate(tpl, vars)) {
- return out
- }
- }
-
- if vars["audience"] != "" && strings.TrimSpace(cfg.PlanAudience) != "" {
- if add(renderQueryTemplate(cfg.PlanAudience, vars)) {
- return out
- }
- }
-
- peripheralLimit := cfg.MaxPeripheralQueries
- if in.Strategy == ExpandStrategyHybrid && peripheralLimit > 1 {
- peripheralLimit = 1
- }
- if peripheralLimit <= 0 {
- peripheralLimit = 2
- }
- for i, tpl := range cfg.PlanPeripheral {
- if i >= peripheralLimit {
- break
- }
- if add(renderQueryTemplate(tpl, vars)) {
- return out
- }
- }
-
- l1Limit := cfg.MaxL1Labels
- if l1Limit <= 0 {
- l1Limit = 2
- }
- for i, label := range in.L1Labels {
- if i >= l1Limit {
- break
- }
- label = strings.TrimSpace(label)
- if label == "" || label == seed {
- continue
- }
- l1vars := map[string]string{"seed": seed, "label": label}
- if add(renderQueryTemplate(cfg.PlanL1Pain, l1vars)) {
- return out
- }
- }
- return out
-}
-
-func supplementalQueries(cfg queryConfig, in PlanInput) []string {
- seed := strings.TrimSpace(in.Seed)
- if seed == "" {
- return nil
- }
- budget := queryBudget(cfg, in.Strategy, true)
- if budget <= 0 {
- return nil
- }
- seen := map[string]struct{}{}
- out := make([]string, 0, budget)
- add := func(q string) {
- q = strings.TrimSpace(q)
- if q == "" {
- return
- }
- if _, ok := seen[q]; ok {
- return
- }
- seen[q] = struct{}{}
- out = append(out, q)
- }
- vars := map[string]string{"seed": seed}
- for _, tpl := range cfg.Supplemental {
- add(renderQueryTemplate(tpl, vars))
- }
- for _, pillar := range in.Pillars {
- pillar = strings.TrimSpace(pillar)
- if pillar == "" {
- continue
- }
- if tpl := strings.TrimSpace(cfg.SupplementalPillar); tpl != "" {
- add(renderQueryTemplate(tpl, map[string]string{"pillar": pillar}))
- }
- if len(out) >= budget {
- return capQueries(out, budget)
- }
- }
- for _, label := range in.L1Labels {
- label = strings.TrimSpace(label)
- if label == "" {
- continue
- }
- add(renderQueryTemplate(cfg.SupplementalL1, map[string]string{"seed": seed, "label": label}))
- if len(out) >= budget {
- break
- }
- }
- return capQueries(out, budget)
-}
-
-func PlanBreadthQueries(in PlanInput) []string {
- in.Supplemental = true
- return PlanQueries(in)
-}
-
-// PlanBootstrapQueries builds Brave queries that do not depend on a generated research map.
-func PlanBootstrapQueries(in PlanInput) []string {
- bootstrap := in
- bootstrap.Pillars = nil
- bootstrap.Questions = nil
- bootstrap.L1Labels = nil
- bootstrap.Supplemental = false
- return PlanQueries(bootstrap)
-}
-
-// QueriesExcept returns planned queries that were not already executed.
-func QueriesExcept(planned, executed []string) []string {
- done := map[string]struct{}{}
- for _, q := range executed {
- q = strings.TrimSpace(q)
- if q == "" {
- continue
- }
- done[q] = struct{}{}
- }
- out := make([]string, 0, len(planned))
- for _, q := range planned {
- q = strings.TrimSpace(q)
- if q == "" {
- continue
- }
- if _, ok := done[q]; ok {
- continue
- }
- out = append(out, q)
- }
- return out
-}
-
-func BuildRecencyQuery(label string) string {
- cfg, err := loadQueryConfig()
- if err != nil {
- return ""
- }
- label = strings.TrimSpace(label)
- if label == "" {
- return ""
- }
- if strings.ContainsAny(label, cfg.RecencyHelpMarkers) {
- return label
- }
- suffix := strings.TrimSpace(cfg.RecencySuffix)
- if suffix == "" {
- suffix = "請問"
- }
- return fmt.Sprintf("%s %s", label, suffix)
-}
-
-func renderQueryTemplate(tpl string, vars map[string]string) string {
- out := tpl
- for key, value := range vars {
- out = strings.ReplaceAll(out, "{{"+key+"}}", value)
- }
- return strings.TrimSpace(out)
-}
-
-func capQueries(items []string, max int) []string {
- if max <= 0 || len(items) <= max {
- return items
- }
- return items[:max]
-}
-
-func L1LabelsFromNodes(nodes []Node) []string {
- out := make([]string, 0, len(nodes))
- for _, node := range nodes {
- if node.Layer != 1 {
- continue
- }
- label := strings.TrimSpace(node.Label)
- if label != "" {
- out = append(out, label)
- }
- }
- return out
-}
diff --git a/old/backend/internal/library/knowledge/queries_test.go b/old/backend/internal/library/knowledge/queries_test.go
deleted file mode 100644
index fcaa09a..0000000
--- a/old/backend/internal/library/knowledge/queries_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package knowledge
-
-import (
- "testing"
- "unicode/utf8"
-)
-
-func TestPlanQueriesCapsAtConfigLimit(t *testing.T) {
- queries := PlanQueries(PlanInput{
- Seed: "敏感肌",
- TargetAudience: "孕婦",
- L1Labels: []string{"a", "b", "c", "d", "e", "f", "g", "h"},
- })
- max := MaxPlanQueriesPerRound()
- if len(queries) > max {
- t.Fatalf("expected <= %d queries, got %d", max, len(queries))
- }
- if len(queries) < 4 {
- t.Fatalf("expected at least 4 queries, got %d", len(queries))
- }
-}
-
-func TestDeriveSearchTagsFromGraph(t *testing.T) {
- graph := Graph{
- Nodes: []Node{
- {ID: "n1", Label: "敏感肌", NodeKind: "pain", Layer: 0},
- {ID: "n2", Label: "屏障受損", NodeKind: "symptom", Layer: 1},
- },
- }
- DeriveSearchTagsFromGraph(&graph, PatrolTagInput{
- Questions: []string{"敏感肌沐浴乳有推薦嗎"},
- Pillars: []string{"敏感肌沐浴用品挑選"},
- })
- if graph.PainTagCount != 2 {
- t.Fatalf("expected pain tag count 2, got %d", graph.PainTagCount)
- }
- for _, tag := range graph.Nodes[0].DerivedTags.Relevance {
- if utf8.RuneCountInString(tag) < 6 {
- t.Fatalf("expected human-length tag, got %q", tag)
- }
- }
- if len(graph.Nodes[0].DerivedTags.Relevance) == 0 {
- t.Fatal("expected relevance tags on core node")
- }
- if len(graph.Nodes[0].DerivedTags.Recency) == 0 {
- t.Fatal("expected recency tags on pain node")
- }
-}
diff --git a/old/backend/internal/library/knowledge/research_map_fit.go b/old/backend/internal/library/knowledge/research_map_fit.go
deleted file mode 100644
index 2ed550e..0000000
--- a/old/backend/internal/library/knowledge/research_map_fit.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package knowledge
-
-import (
- "sort"
- "strings"
-)
-
-// RankStringsByIntent sorts phrases by semantic proximity to product + research map.
-func RankStringsByIntent(items []string, in PatrolTagInput) []string {
- if len(items) <= 1 {
- return append([]string{}, items...)
- }
- profile := BuildIntentProfile(in)
- type scored struct {
- text string
- score int
- }
- scores := make([]scored, 0, len(items))
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- scores = append(scores, scored{
- text: item,
- score: ScoreIntentSimilarity(item, profile),
- })
- }
- sort.SliceStable(scores, func(i, j int) bool {
- if scores[i].score == scores[j].score {
- return scores[i].text < scores[j].text
- }
- return scores[i].score > scores[j].score
- })
- out := make([]string, 0, len(scores))
- for _, item := range scores {
- out = append(out, item.text)
- }
- return out
-}
-
-// FilterPatrolKeywordsByIntent drops low-intent patrol keywords from research map output.
-func FilterPatrolKeywordsByIntent(items []string, in PatrolTagInput, minScore int) []string {
- if minScore <= 0 {
- return RankStringsByIntent(items, in)
- }
- profile := BuildIntentProfile(in)
- out := make([]string, 0, len(items))
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- if ScoreIntentSimilarity(item, profile) >= minScore {
- out = append(out, item)
- }
- }
- return out
-}
diff --git a/old/backend/internal/library/knowledge/search_keyword_shape.go b/old/backend/internal/library/knowledge/search_keyword_shape.go
deleted file mode 100644
index 2a617a4..0000000
--- a/old/backend/internal/library/knowledge/search_keyword_shape.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "unicode/utf8"
-)
-
-const (
- maxThreadsSearchRunes = 14
- maxWebRecencyCoreRunes = 12
- maxWebRelevanceTagRunes = 16
-)
-
-var searchIntentMarkers = []string{"推薦", "請問", "怎麼辦", "好用嗎", "有人", "求助", "請益", "有推薦"}
-
-// ShortPatrolSearchCore compresses a patrol tag for Threads API / recency Search API.
-// Keeps pain + product tokens; drops filler and redundant intent words (RECENT/TOP handles freshness).
-func ShortPatrolSearchCore(tag string) string {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return ""
- }
- tokens := selectSearchTokens(tag, 3)
- if len(tokens) == 0 {
- return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
- }
- core := strings.Join(tokens, " ")
- return truncateRunes(core, maxWebRecencyCoreRunes)
-}
-
-// ThreadsAPIKeyword is the plain keyword sent to official Threads keyword search.
-func ThreadsAPIKeyword(tag string) string {
- core := ShortPatrolSearchCore(tag)
- if core != "" {
- return core
- }
- return truncateRunes(stripSearchIntent(tag), maxThreadsSearchRunes)
-}
-
-// FullPatrolWebSearchTag keeps a slightly longer phrase for relevance Search API (exact-match quotes).
-func FullPatrolWebSearchTag(tag string) string {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return ""
- }
- if !hasSearchIntentMarker(tag) {
- candidate := strings.TrimSpace(tag + " 推薦")
- if utf8.RuneCountInString(candidate) <= maxWebRelevanceTagRunes {
- tag = candidate
- }
- }
- if utf8.RuneCountInString(tag) <= maxWebRelevanceTagRunes {
- return tag
- }
- tokens := selectSearchTokens(tag, 4)
- if len(tokens) == 0 {
- return truncateRunes(tag, maxWebRelevanceTagRunes)
- }
- full := strings.Join(tokens, " ")
- if !hasSearchIntentMarker(full) {
- withIntent := strings.TrimSpace(full + " 推薦")
- if utf8.RuneCountInString(withIntent) <= maxWebRelevanceTagRunes {
- return withIntent
- }
- }
- return truncateRunes(full, maxWebRelevanceTagRunes)
-}
-
-func selectSearchTokens(tag string, maxTokens int) []string {
- tag = stripSearchIntent(tag)
- parts := []string{}
- seen := map[string]struct{}{}
- add := func(token string) {
- token = strings.TrimSpace(token)
- if isPatrolFiller(token) || len([]rune(token)) < 2 {
- return
- }
- if _, ok := seen[token]; ok {
- return
- }
- seen[token] = struct{}{}
- parts = append(parts, token)
- }
-
- for _, anchor := range patrolTopicAnchors {
- if strings.Contains(tag, anchor) {
- add(anchor)
- }
- }
- for _, hint := range productFormHints {
- if strings.Contains(tag, hint) {
- add(hint)
- }
- }
- for _, token := range intentTokenize(tag) {
- add(token)
- if len(parts) >= maxTokens {
- break
- }
- }
- if len(parts) > maxTokens {
- parts = parts[:maxTokens]
- }
- return parts
-}
-
-func stripSearchIntent(tag string) string {
- tag = strings.TrimSpace(tag)
- for _, marker := range searchIntentMarkers {
- tag = strings.ReplaceAll(tag, marker, " ")
- }
- return strings.Join(strings.Fields(tag), " ")
-}
-
-func hasSearchIntentMarker(tag string) bool {
- for _, marker := range searchIntentMarkers {
- if strings.Contains(tag, marker) {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/knowledge/search_keyword_shape_test.go b/old/backend/internal/library/knowledge/search_keyword_shape_test.go
deleted file mode 100644
index f3b4bde..0000000
--- a/old/backend/internal/library/knowledge/search_keyword_shape_test.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "testing"
-)
-
-func TestShortPatrolSearchCoreDropsIntentNoise(t *testing.T) {
- got := ShortPatrolSearchCore("化療 無香 洗衣精 推薦 請問")
- if got == "" {
- t.Fatal("expected non-empty core")
- }
- if len([]rune(got)) > maxThreadsSearchRunes {
- t.Fatalf("core too long: %q", got)
- }
- if strings.Contains(got, "推薦") || strings.Contains(got, "請問") {
- t.Fatalf("expected intent words stripped for API core, got %q", got)
- }
- if !strings.Contains(got, "無香") && !strings.Contains(got, "洗衣精") {
- t.Fatalf("expected pain/product tokens kept, got %q", got)
- }
-}
-
-func TestFullPatrolWebSearchTagKeepsIntent(t *testing.T) {
- got := FullPatrolWebSearchTag("化療 無香 洗衣精")
- if !strings.Contains(got, "推薦") && !strings.Contains(got, "請問") && !strings.Contains(got, "怎麼辦") {
- t.Fatalf("expected relevance tag to include intent marker, got %q", got)
- }
-}
-
-func TestThreadsAPIKeywordUsesShortCore(t *testing.T) {
- long := "化療後皮膚敏感要換什麼無香洗衣精 推薦"
- got := ThreadsAPIKeyword(long)
- if got == "" || len([]rune(got)) > maxThreadsSearchRunes {
- t.Fatalf("unexpected threads keyword %q", got)
- }
-}
diff --git a/old/backend/internal/library/knowledge/semantic_fit.go b/old/backend/internal/library/knowledge/semantic_fit.go
deleted file mode 100644
index b430d0b..0000000
--- a/old/backend/internal/library/knowledge/semantic_fit.go
+++ /dev/null
@@ -1,289 +0,0 @@
-package knowledge
-
-import (
- "strings"
- "unicode"
-)
-
-// IntentProfile is a weighted lexical intent model for product + research map.
-// It generalizes relevance beyond hardcoded category rules (e.g. 洗衣精 vs 洗衣機).
-type IntentProfile struct {
- TokenWeights map[string]float64
- BroadTokens map[string]struct{}
- Phrases []weightedPhrase
-}
-
-type weightedPhrase struct {
- Text string
- Weight float64
-}
-
-const (
- intentWeightProduct = 3.0
- intentWeightFeature = 2.8
- intentWeightMatchTag = 2.5
- intentWeightQuestion = 2.2
- intentWeightPillar = 2.0
- intentWeightAudience = 1.6
- intentWeightPatrol = 1.2
- intentWeightSeed = 1.0
-
- tangentialBroadMin = 38
- tangentialIntentMax = 30
-)
-
-var productFormHints = []string{
- "洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
- "沐浴乳", "沐浴露", "洗髮精", "洗髮乳", "洗面乳",
-}
-
-// BuildIntentProfile materializes product + research-map text into a reusable relevance model.
-func BuildIntentProfile(in PatrolTagInput) IntentProfile {
- profile := IntentProfile{
- TokenWeights: map[string]float64{},
- BroadTokens: map[string]struct{}{},
- }
- addPhrase := func(text string, weight float64) {
- text = strings.TrimSpace(text)
- if text == "" || weight <= 0 {
- return
- }
- profile.Phrases = append(profile.Phrases, weightedPhrase{Text: text, Weight: weight})
- profile.addTokens(text, weight)
- }
-
- addPhrase(in.ProductName, intentWeightProduct)
- addPhrase(in.ProductFeatures, intentWeightFeature)
- for _, tag := range in.MatchTags {
- addPhrase(tag, intentWeightMatchTag)
- }
- for _, q := range in.Questions {
- addPhrase(q, intentWeightQuestion)
- }
- for _, pillar := range in.Pillars {
- addPhrase(pillar, intentWeightPillar)
- }
- addPhrase(in.AudienceSummary, intentWeightAudience)
- addPhrase(in.TargetAudience, intentWeightAudience)
- for _, kw := range in.PatrolKeywords {
- addPhrase(kw, intentWeightPatrol)
- }
-
- if hint := productCategoryHint(in.ProductName, in.ProductFeatures); hint != "" {
- profile.markBroad(hint)
- }
- for _, hint := range productFormHints {
- blob := strings.ToLower(in.ProductName + " " + in.ProductFeatures + " " + strings.Join(in.MatchTags, " "))
- if strings.Contains(blob, hint) {
- profile.markBroad(hint)
- }
- }
- return profile
-}
-
-func (p *IntentProfile) addTokens(text string, weight float64) {
- for _, token := range intentTokenize(text) {
- if existing, ok := p.TokenWeights[token]; !ok || weight > existing {
- p.TokenWeights[token] = weight
- }
- }
-}
-
-func (p *IntentProfile) markBroad(token string) {
- token = strings.TrimSpace(strings.ToLower(token))
- if token == "" {
- return
- }
- p.BroadTokens[token] = struct{}{}
- for _, part := range intentTokenize(token) {
- p.BroadTokens[part] = struct{}{}
- }
-}
-
-func (p IntentProfile) totalWeight() float64 {
- sum := 0.0
- for _, w := range p.TokenWeights {
- sum += w
- }
- if sum <= 0 {
- for _, phrase := range p.Phrases {
- sum += phrase.Weight
- }
- }
- return sum
-}
-
-// NodeIntentText combines node fields used for semantic fit.
-func NodeIntentText(node Node) string {
- return strings.TrimSpace(strings.Join([]string{
- node.Label,
- node.Relation,
- node.PlacementValue,
- strings.Join(node.PatrolRelevance, " "),
- strings.Join(node.PatrolRecency, " "),
- }, " "))
-}
-
-// ScoreIntentSimilarity returns 0-100 weighted token overlap against the intent profile.
-func ScoreIntentSimilarity(text string, profile IntentProfile) int {
- text = strings.ToLower(strings.TrimSpace(text))
- if text == "" || len(profile.TokenWeights) == 0 {
- return 0
- }
- matched := 0.0
- for token, weight := range profile.TokenWeights {
- if strings.Contains(text, token) {
- matched += weight
- }
- }
- total := profile.totalWeight()
- if total <= 0 {
- return 0
- }
- score := int((matched / total) * 100)
- if score > 100 {
- return 100
- }
- return score
-}
-
-// ScoreBroadSimilarity measures overlap with category-level tokens only.
-func ScoreBroadSimilarity(text string, profile IntentProfile) int {
- text = strings.ToLower(strings.TrimSpace(text))
- if text == "" || len(profile.BroadTokens) == 0 {
- return 0
- }
- hits := 0
- for token := range profile.BroadTokens {
- if strings.Contains(text, token) {
- hits++
- continue
- }
- runes := []rune(token)
- if len(runes) >= 2 && strings.Contains(text, string(runes[:2])) {
- hits++
- }
- }
- if hits == 0 {
- return 0
- }
- denom := len(profile.BroadTokens)
- if denom > 6 {
- denom = 6
- }
- score := (hits * 100) / denom
- if score > 100 {
- return 100
- }
- return score
-}
-
-// IsTangentialToIntent detects same broad category but weak product-intent alignment.
-// Example: 洗衣機 vs 抗敏無香洗衣精 — shares 洗衣, lacks 無香/敏感/化療 intent.
-func IsTangentialToIntent(text string, profile IntentProfile) bool {
- text = strings.ToLower(strings.TrimSpace(text))
- if text == "" || len(profile.TokenWeights) == 0 {
- return false
- }
- intentScore := ScoreIntentSimilarity(text, profile)
- if intentScore >= tangentialIntentMax+10 {
- return false
- }
- if hasCategoryStemDrift(text, profile) {
- return true
- }
- broadScore := ScoreBroadSimilarity(text, profile)
- if broadScore >= tangentialBroadMin {
- return intentScore <= tangentialIntentMax
- }
- return false
-}
-
-func hasCategoryStemDrift(text string, profile IntentProfile) bool {
- for broad := range profile.BroadTokens {
- runes := []rune(broad)
- if len(runes) < 2 {
- continue
- }
- stem := string(runes[:2])
- if !strings.Contains(text, stem) {
- continue
- }
- if strings.Contains(text, broad) {
- continue
- }
- for _, suffix := range []string{"機", "槽", "烘", "劑", "粉", "精", "乳", "露", "液", "皂"} {
- alt := stem + suffix
- if alt == broad || !strings.Contains(text, alt) {
- continue
- }
- if !profile.matchesIntentToken(alt) {
- return true
- }
- }
- }
- return false
-}
-
-func (p IntentProfile) matchesIntentToken(token string) bool {
- if _, ok := p.TokenWeights[token]; ok {
- return true
- }
- for intentToken := range p.TokenWeights {
- if strings.Contains(token, intentToken) || strings.Contains(intentToken, token) {
- return true
- }
- }
- return false
-}
-
-func intentTokenize(text string) []string {
- text = strings.ToLower(strings.TrimSpace(text))
- if text == "" {
- return nil
- }
- repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ", "?", " ", "?", " ", "!", " ", "!", " ")
- text = repl.Replace(text)
- seen := map[string]struct{}{}
- var out []string
- add := func(token string) {
- token = strings.Trim(token, `"'「」『』::`)
- if len([]rune(token)) < 2 {
- return
- }
- if _, ok := seen[token]; ok {
- return
- }
- seen[token] = struct{}{}
- out = append(out, token)
- }
- for _, part := range strings.Fields(text) {
- add(part)
- }
- if len(out) == 0 {
- for _, chunk := range intentSplitRunes(text) {
- add(chunk)
- }
- }
- return out
-}
-
-func intentSplitRunes(s string) []string {
- var out []string
- var buf []rune
- flush := func() {
- if len(buf) >= 2 {
- out = append(out, string(buf))
- }
- buf = buf[:0]
- }
- for _, r := range s {
- if unicode.Is(unicode.Han, r) {
- buf = append(buf, r)
- continue
- }
- flush()
- }
- flush()
- return out
-}
diff --git a/old/backend/internal/library/knowledge/semantic_fit_test.go b/old/backend/internal/library/knowledge/semantic_fit_test.go
deleted file mode 100644
index 449f6bf..0000000
--- a/old/backend/internal/library/knowledge/semantic_fit_test.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestIsTangentialToIntentWashingMachineVsHypoallergenicDetergent(t *testing.T) {
- profile := BuildIntentProfile(PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏,適合敏感肌與化療族群",
- MatchTags: []string{"無香", "抗敏", "洗衣精"},
- Pillars: []string{"化療後衣物清潔", "無香洗衣"},
- Questions: []string{"化療後想找不會刺激的洗衣精"},
- })
- text := "請問洗脫烘洗衣機有推薦嗎?"
- if !IsTangentialToIntent(text, profile) {
- t.Fatal("expected washing machine post to be tangential to hypoallergenic detergent intent")
- }
-}
-
-func TestIsTangentialToIntentAcceptsCorePain(t *testing.T) {
- profile := BuildIntentProfile(PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏",
- MatchTags: []string{"無香", "洗衣精"},
- })
- text := "化療後對香味很敏感,有無香洗衣精推薦嗎?"
- if IsTangentialToIntent(text, profile) {
- t.Fatal("expected core pain post to align with intent")
- }
-}
-
-func TestRescoreGraphIntentFitPenalizesTangentialNode(t *testing.T) {
- graph := Graph{
- Seed: "無香 洗衣精",
- Nodes: []Node{
- {ID: "core", Label: "化療後對香味敏感", NodeKind: "pain", Layer: 0, ProductFitScore: 92, Relation: "需要無香洗衣精"},
- {ID: "weak", Label: "洗衣機選購", NodeKind: "knowledge", Layer: 2, ProductFitScore: 72, Relation: "家電預算與容量"},
- },
- Edges: []Edge{{From: "core", To: "weak", Relation: "延伸"}},
- }
- in := PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏",
- MatchTags: []string{"無香", "洗衣精"},
- Questions: []string{"化療後想找無香洗衣精"},
- }
- RescoreGraphIntentFit(&graph, in)
- if graph.Nodes[0].ProductFitScore < 60 {
- t.Fatalf("expected core node to stay high, got %d", graph.Nodes[0].ProductFitScore)
- }
- if graph.Nodes[1].ProductFitScore > 35 {
- t.Fatalf("expected tangential node to be penalized, got %d", graph.Nodes[1].ProductFitScore)
- }
-}
diff --git a/old/backend/internal/library/knowledge/synth.go b/old/backend/internal/library/knowledge/synth.go
deleted file mode 100644
index 9c3cc6d..0000000
--- a/old/backend/internal/library/knowledge/synth.go
+++ /dev/null
@@ -1,387 +0,0 @@
-package knowledge
-
-import (
- "fmt"
- "regexp"
- "strings"
-
- "haixun-backend/internal/library/llmjson"
- libprompt "haixun-backend/internal/library/prompt"
-
- "github.com/google/uuid"
-)
-
-type SynthInput struct {
- BrandDisplayName string
- TopicName string
- ProductLabel string
- Goals string
- Seed string
- ProductBrief string
- TargetAudience string
- Persona string
- ResearchPillars []string
- ResearchQuestions []string
- Sources []BraveSource
-}
-
-type rawSynthNode struct {
- Label string `json:"label"`
- NodeKind string `json:"nodeKind"`
- NodeKindSnake string `json:"node_kind"`
- Type string `json:"type"`
- Layer int `json:"layer"`
- Relation string `json:"relation"`
- PlacementValue string `json:"placementValue"`
- PlacementValueAlt string `json:"placement_value"`
- ProductFitScore int `json:"productFitScore"`
- ProductFitScoreAlt int `json:"product_fit_score"`
- EvidenceURLs []string `json:"evidenceUrls"`
- EvidenceURLsAlt []string `json:"evidence_urls"`
- RelevanceQueries []string `json:"relevanceQueries"`
- RelevanceQueriesAlt []string `json:"relevance_queries"`
- RecencyQueries []string `json:"recencyQueries"`
- RecencyQueriesAlt []string `json:"recency_queries"`
-}
-
-type rawSynthOutput struct {
- Nodes []rawSynthNode `json:"nodes"`
- Edges []struct {
- From string `json:"from"`
- To string `json:"to"`
- Relation string `json:"relation"`
- } `json:"edges"`
-}
-
-var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-func BuildUserPrompt(in SynthInput) (string, error) {
- var sources strings.Builder
- limit := len(in.Sources)
- if limit > 30 {
- limit = 30
- }
- for i := 0; i < limit; i++ {
- src := in.Sources[i]
- fmt.Fprintf(&sources, "[%d] query=%s\nurl=%s\ntitle=%s\nsnippet=%s\n\n",
- i+1, src.Query, src.URL, src.Title, src.Snippet)
- }
- vars := map[string]string{
- "brand_line": optionalLine("品牌", in.BrandDisplayName),
- "topic_line": optionalLine("主題名稱", in.TopicName),
- "product_line": optionalLine("置入產品", in.ProductLabel),
- "goals_line": optionalLine("置入目標", in.Goals),
- "seed": strings.TrimSpace(in.Seed),
- "product_brief_line": optionalLine("產品簡述", in.ProductBrief),
- "target_audience_line": optionalLine("目標受眾", in.TargetAudience),
- "persona_line": optionalLine("主題目標", in.Persona),
- "research_pillars_line": bulletLine("內容支柱(延伸知識要往這些方向廣泛展開)", in.ResearchPillars),
- "research_questions_line": bulletLine("受眾提問方向(可衍生成更多周邊節點)", in.ResearchQuestions),
- "sources": strings.TrimSpace(sources.String()),
- }
- return libprompt.KnowledgeGraphUser(vars)
-}
-
-func optionalLine(label, value string) string {
- return OptionalPromptLine(label, value)
-}
-
-func BulletPromptLine(title string, items []string) string {
- return bulletLine(title, items)
-}
-
-func bulletLine(title string, items []string) string {
- lines := make([]string, 0, len(items))
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- lines = append(lines, "- "+item)
- }
- if len(lines) == 0 {
- return ""
- }
- return title + ":\n" + strings.Join(lines, "\n") + "\n"
-}
-
-func OptionalPromptLine(label, value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
- return label + ":" + value + "\n"
-}
-
-func ParseSynthOutput(raw string, in SynthInput, sources []BraveSource) (Graph, error) {
- payload, err := extractJSONObject(raw)
- if err != nil {
- return Graph{}, err
- }
- out, err := unmarshalSynthOutput(payload)
- if err != nil {
- return Graph{}, err
- }
-
- seed := strings.TrimSpace(in.Seed)
- graph := Graph{
- Seed: seed,
- BraveSources: sources,
- Nodes: []Node{},
- Edges: []Edge{},
- }
-
- sourceByURL := map[string]BraveSource{}
- for _, src := range sources {
- if src.URL != "" {
- sourceByURL[src.URL] = src
- }
- }
-
- hasCore := false
- for _, item := range out.Nodes {
- label := strings.TrimSpace(item.Label)
- if label == "" {
- continue
- }
- layer := item.Layer
- nodeType := strings.TrimSpace(item.Type)
- nodeKind := firstNonEmpty(item.NodeKind, item.NodeKindSnake)
- if layer == 0 || nodeType == "core" {
- layer = 0
- nodeType = "core"
- if nodeKind == "" {
- nodeKind = "pain"
- }
- hasCore = true
- }
- if nodeKind == "" {
- if layer >= 2 {
- nodeKind = "cause"
- } else if layer == 1 {
- nodeKind = "symptom"
- } else {
- nodeKind = "knowledge"
- }
- }
- evidenceURLs := item.EvidenceURLs
- if len(evidenceURLs) == 0 {
- evidenceURLs = item.EvidenceURLsAlt
- }
- evidence := make([]Evidence, 0, len(evidenceURLs))
- for _, u := range evidenceURLs {
- u = strings.TrimSpace(u)
- if u == "" {
- continue
- }
- ev := Evidence{URL: u}
- if src, ok := sourceByURL[u]; ok {
- ev.Snippet = src.Snippet
- ev.Query = src.Query
- }
- evidence = append(evidence, ev)
- }
- fit := item.ProductFitScore
- if fit <= 0 {
- fit = item.ProductFitScoreAlt
- }
- if fit <= 0 {
- fit = defaultProductFit(nodeKind, layer)
- }
- placementValue := firstNonEmpty(item.PlacementValue, item.PlacementValueAlt)
- graph.Nodes = append(graph.Nodes, Node{
- ID: uuid.NewString(),
- Label: label,
- NodeKind: nodeKind,
- Type: nodeType,
- Layer: layer,
- Relation: strings.TrimSpace(item.Relation),
- PlacementValue: normalizePlacementReason(placementValue, item.Relation, nodeKind, fit),
- ProductFitScore: fit,
- PatrolRelevance: mergeStringLists(item.RelevanceQueries, item.RelevanceQueriesAlt),
- PatrolRecency: mergeStringLists(item.RecencyQueries, item.RecencyQueriesAlt),
- Evidence: evidence,
- })
- }
-
- if !hasCore && seed != "" {
- graph.Nodes = append([]Node{{
- ID: uuid.NewString(),
- Label: seed,
- NodeKind: "pain",
- Type: "core",
- Layer: 0,
- Relation: "核心種子主題",
- PlacementValue: "核心痛點帖最常求推薦,適合以產品使用經驗自然回覆",
- ProductFitScore: 90,
- }}, graph.Nodes...)
- }
-
- labelToID := map[string]string{}
- for _, node := range graph.Nodes {
- labelToID[strings.ToLower(strings.TrimSpace(node.Label))] = node.ID
- }
- for _, edge := range out.Edges {
- from := resolveNodeRef(edge.From, labelToID, graph.Nodes)
- to := resolveNodeRef(edge.To, labelToID, graph.Nodes)
- if from == "" || to == "" || from == to {
- continue
- }
- graph.Edges = append(graph.Edges, Edge{
- From: from,
- To: to,
- Relation: strings.TrimSpace(edge.Relation),
- })
- }
-
- return graph, nil
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
-}
-
-func mergeStringLists(groups ...[]string) []string {
- out := []string{}
- seen := map[string]struct{}{}
- for _, group := range groups {
- for _, item := range group {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- if _, ok := seen[item]; ok {
- continue
- }
- seen[item] = struct{}{}
- out = append(out, item)
- }
- }
- return out
-}
-
-func defaultProductFit(nodeKind string, layer int) int {
- switch nodeKind {
- case "pain":
- if layer == 0 {
- return 90
- }
- return 80
- case "symptom", "cause":
- return 70
- default:
- return 50
- }
-}
-
-func normalizePlacementReason(value, relation, nodeKind string, productFit int) string {
- value = strings.TrimSpace(value)
- if value != "" {
- switch strings.ToLower(value) {
- case "high":
- return "受眾在此情境常有明確產品需求,適合自然分享使用經驗"
- case "medium":
- return "與產品使用情境相關,可輕量帶入經驗而不硬推"
- case "low":
- return "多為背景脈絡,置入需非常克制"
- default:
- return value
- }
- }
- relation = strings.TrimSpace(relation)
- if relation != "" && productFit >= 70 && IsPainNode(Node{NodeKind: nodeKind}) {
- return "與「" + relation + "」相關的求助帖,有機會自然帶入產品經驗"
- }
- if IsPainNode(Node{NodeKind: nodeKind}) {
- return "痛點類討論串,可視情境分享產品使用心得"
- }
- if productFit >= 60 {
- return "與產品使用情境相關,可作輕量經驗分享"
- }
- return ""
-}
-
-func resolveNodeRef(ref string, labelToID map[string]string, nodes []Node) string {
- ref = strings.TrimSpace(ref)
- if ref == "" {
- return ""
- }
- for _, node := range nodes {
- if node.ID == ref {
- return node.ID
- }
- }
- if id, ok := labelToID[strings.ToLower(ref)]; ok {
- return id
- }
- return ""
-}
-
-// unmarshalSynthOutput parses the knowledge graph payload, tolerating the small
-// JSON artifacts (e.g. `...` placeholders, trailing/duplicate commas) that LLMs
-// occasionally emit.
-func unmarshalSynthOutput(payload []byte) (rawSynthOutput, error) {
- var out rawSynthOutput
- if err := llmjson.Unmarshal(payload, &out); err != nil {
- return rawSynthOutput{}, fmt.Errorf("parse knowledge graph json: %w", err)
- }
- return out, nil
-}
-
-func extractJSONObject(raw string) ([]byte, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return nil, fmt.Errorf("empty LLM response")
- }
- if m := codeFenceRE.FindStringSubmatch(text); len(m) == 2 {
- text = strings.TrimSpace(m[1])
- }
- start := strings.Index(text, "{")
- if start < 0 {
- return nil, fmt.Errorf("LLM response does not contain JSON object")
- }
- end, ok := matchJSONObjectEnd(text, start)
- if !ok {
- return nil, fmt.Errorf("LLM response does not contain complete JSON object")
- }
- return []byte(text[start : end+1]), nil
-}
-
-func matchJSONObjectEnd(text string, start int) (int, bool) {
- depth := 0
- inString := false
- escaped := false
- for i := start; i < len(text); i++ {
- ch := text[i]
- if inString {
- if escaped {
- escaped = false
- continue
- }
- switch ch {
- case '\\':
- escaped = true
- case '"':
- inString = false
- }
- continue
- }
- switch ch {
- case '"':
- inString = true
- case '{':
- depth++
- case '}':
- depth--
- if depth == 0 {
- return i, true
- }
- }
- }
- return 0, false
-}
diff --git a/old/backend/internal/library/knowledge/synth_test.go b/old/backend/internal/library/knowledge/synth_test.go
deleted file mode 100644
index 868510b..0000000
--- a/old/backend/internal/library/knowledge/synth_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package knowledge
-
-import "testing"
-
-func TestNormalizePlacementReason(t *testing.T) {
- got := normalizePlacementReason("high", "", "pain", 90)
- if got == "" || got == "high" {
- t.Fatalf("expected prose for legacy high, got %q", got)
- }
-
- got = normalizePlacementReason("換季泛紅帖常求溫和修護產品", "", "pain", 85)
- if got != "換季泛紅帖常求溫和修護產品" {
- t.Fatalf("expected prose preserved, got %q", got)
- }
-}
-
-func TestExtractJSONObject(t *testing.T) {
- valid := `{"nodes":[{"label":"a","evidenceUrls":[]}],"edges":[]}`
- cases := []struct {
- name string
- raw string
- wantErr bool
- }{
- {name: "plain", raw: valid},
- {name: "prefixed text", raw: "分析完成。\n" + valid},
- {name: "code fence", raw: "說明如下:\n```json\n" + valid + "\n```"},
- {name: "trailing object", raw: valid + `,{"summary":"ignored"}`},
- {name: "brace in string", raw: `{"nodes":[{"label":"含{符號}的節點","evidenceUrls":[]}],"edges":[]}`},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- payload, err := extractJSONObject(tc.raw)
- if tc.wantErr {
- if err == nil {
- t.Fatal("expected error")
- }
- return
- }
- if err != nil {
- t.Fatal(err)
- }
- graph, err := ParseSynthOutput(string(payload), SynthInput{Seed: "種子"}, nil)
- if err != nil {
- t.Fatal(err)
- }
- if len(graph.Nodes) == 0 {
- t.Fatal("expected nodes")
- }
- })
- }
-}
-
-func TestParseSynthOutputToleratesEllipsisAndTrailingCommas(t *testing.T) {
- // LLM emits `...` placeholders, a trailing comma, and a duplicated comma.
- raw := "```json\n" + `{
- "nodes": [
- {
- "label": "敏感肌泛紅",
- "nodeKind": "symptom",
- "type": "symptom",
- "layer": 1,
- "relation": "溫差導致泛紅",
- "evidenceUrls": ["https://example.com/a"],
- },
- ...,
- {
- "label": "保濕不足",
- "nodeKind": "cause",
- "layer": 2,
- "evidenceUrls": [],
- }
- ],
- "edges": [...]
-}` + "\n```"
- payload, err := extractJSONObject(raw)
- if err != nil {
- t.Fatalf("extractJSONObject error: %v", err)
- }
- graph, err := ParseSynthOutput(string(payload), SynthInput{Seed: "敏感肌"}, nil)
- if err != nil {
- t.Fatalf("ParseSynthOutput error: %v", err)
- }
- labels := map[string]bool{}
- for _, n := range graph.Nodes {
- labels[n.Label] = true
- }
- if !labels["敏感肌泛紅"] || !labels["保濕不足"] {
- t.Fatalf("expected both real nodes parsed, got %+v", labels)
- }
- // The URL containing "//" must survive sanitization.
- found := false
- for _, n := range graph.Nodes {
- for _, ev := range n.Evidence {
- if ev.URL == "https://example.com/a" {
- found = true
- }
- }
- }
- if !found {
- t.Fatal("expected evidence URL to survive sanitization")
- }
-}
-
-func TestParseSynthOutputPlacementReason(t *testing.T) {
- raw := `{
- "nodes": [
- {
- "label": "換季泛紅",
- "nodeKind": "symptom",
- "type": "symptom",
- "layer": 1,
- "relation": "溫差變化會讓敏感肌臉頰泛紅刺痛",
- "placementValue": "求助帖常問舒緩產品,可分享溫和修護的實際經驗",
- "productFitScore": 82,
- "evidenceUrls": []
- }
- ],
- "edges": []
-}`
- graph, err := ParseSynthOutput(raw, SynthInput{Seed: "敏感肌"}, nil)
- if err != nil {
- t.Fatal(err)
- }
- if len(graph.Nodes) < 2 {
- t.Fatalf("expected core + parsed node, got %d nodes", len(graph.Nodes))
- }
- node := graph.Nodes[len(graph.Nodes)-1]
- if node.Relation == "" {
- t.Fatal("expected relation")
- }
- if node.PlacementValue == "" || node.PlacementValue == "high" {
- t.Fatalf("expected placement prose, got %q", node.PlacementValue)
- }
-}
diff --git a/old/backend/internal/library/llmjson/sanitize.go b/old/backend/internal/library/llmjson/sanitize.go
deleted file mode 100644
index 66b055d..0000000
--- a/old/backend/internal/library/llmjson/sanitize.go
+++ /dev/null
@@ -1,184 +0,0 @@
-// Package llmjson tolerates the small JSON artifacts that LLMs sometimes emit
-// (e.g. `...` placeholders, trailing or duplicated commas) so structured
-// outputs can still be parsed instead of failing the whole job.
-package llmjson
-
-import (
- "encoding/json"
- "strings"
-)
-
-// Unmarshal parses payload into v, retrying once on a sanitized copy when the
-// raw bytes are not strictly valid JSON. The original error is returned when
-// the sanitized retry also fails, so callers keep a meaningful message.
-func Unmarshal(payload []byte, v any) error {
- err := json.Unmarshal(payload, v)
- if err == nil {
- return nil
- }
- if cleaned := Sanitize(payload); cleaned != nil {
- if retryErr := json.Unmarshal(cleaned, v); retryErr == nil {
- return nil
- }
- }
- return err
-}
-
-// Sanitize removes ellipsis placeholders, escapes unescaped control characters
-// inside JSON string literals, and normalizes stray commas that sit outside of
-// string literals. It returns nil when nothing changed so callers can avoid a
-// redundant re-parse. String meaning is preserved; only invalid JSON escapes
-// are added.
-func Sanitize(in []byte) []byte {
- normalized := normalizeCommas(stripEllipsis(escapeControlCharsInStrings(string(in))))
- if normalized == string(in) {
- return nil
- }
- return []byte(normalized)
-}
-
-func escapeControlCharsInStrings(s string) string {
- var b strings.Builder
- b.Grow(len(s) + 16)
- inString := false
- escaped := false
- for i := 0; i < len(s); i++ {
- ch := s[i]
- if inString {
- if escaped {
- escaped = false
- b.WriteByte(ch)
- continue
- }
- switch ch {
- case '\\':
- escaped = true
- b.WriteByte(ch)
- case '"':
- inString = false
- b.WriteByte(ch)
- case '\n':
- b.WriteString(`\n`)
- case '\r':
- b.WriteString(`\r`)
- case '\t':
- b.WriteString(`\t`)
- default:
- if ch < 0x20 {
- b.WriteString(`\u00`)
- b.WriteByte(hexDigit(ch >> 4))
- b.WriteByte(hexDigit(ch & 0x0f))
- } else {
- b.WriteByte(ch)
- }
- }
- continue
- }
- if ch == '"' {
- inString = true
- }
- b.WriteByte(ch)
- }
- return b.String()
-}
-
-func hexDigit(v byte) byte {
- if v < 10 {
- return '0' + v
- }
- return 'a' + (v - 10)
-}
-
-func stripEllipsis(s string) string {
- var b strings.Builder
- b.Grow(len(s))
- inString := false
- escaped := false
- for i := 0; i < len(s); i++ {
- ch := s[i]
- if inString {
- b.WriteByte(ch)
- if escaped {
- escaped = false
- } else if ch == '\\' {
- escaped = true
- } else if ch == '"' {
- inString = false
- }
- continue
- }
- if ch == '"' {
- inString = true
- b.WriteByte(ch)
- continue
- }
- if ch == '.' {
- j := i
- for j < len(s) && s[j] == '.' {
- j++
- }
- if j-i >= 2 {
- i = j - 1
- continue
- }
- }
- b.WriteByte(ch)
- }
- return b.String()
-}
-
-func normalizeCommas(s string) string {
- var b strings.Builder
- b.Grow(len(s))
- inString := false
- escaped := false
- var lastMeaningful byte
- for i := 0; i < len(s); i++ {
- ch := s[i]
- if inString {
- b.WriteByte(ch)
- if escaped {
- escaped = false
- } else if ch == '\\' {
- escaped = true
- } else if ch == '"' {
- inString = false
- }
- continue
- }
- if ch == '"' {
- inString = true
- b.WriteByte(ch)
- lastMeaningful = ch
- continue
- }
- if ch == ',' {
- if lastMeaningful == '[' || lastMeaningful == '{' || lastMeaningful == ',' || lastMeaningful == 0 {
- continue
- }
- if next := nextNonSpaceByte(s, i+1); next == ']' || next == '}' {
- continue
- }
- b.WriteByte(ch)
- lastMeaningful = ch
- continue
- }
- b.WriteByte(ch)
- if ch != ' ' && ch != '\t' && ch != '\n' && ch != '\r' {
- lastMeaningful = ch
- }
- }
- return b.String()
-}
-
-func nextNonSpaceByte(s string, from int) byte {
- for i := from; i < len(s); i++ {
- switch s[i] {
- case ' ', '\t', '\n', '\r':
- continue
- default:
- return s[i]
- }
- }
- return 0
-}
diff --git a/old/backend/internal/library/llmjson/sanitize_test.go b/old/backend/internal/library/llmjson/sanitize_test.go
deleted file mode 100644
index 567c111..0000000
--- a/old/backend/internal/library/llmjson/sanitize_test.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package llmjson
-
-import "testing"
-
-func TestUnmarshalToleratesEllipsisAndCommas(t *testing.T) {
- raw := []byte(`{
- "nodes": [
- {"label": "a", "url": "https://example.com/a"},
- ...,
- {"label": "b"},
- ],
- "edges": [...]
-}`)
- var out struct {
- Nodes []struct {
- Label string `json:"label"`
- URL string `json:"url"`
- } `json:"nodes"`
- Edges []any `json:"edges"`
- }
- if err := Unmarshal(raw, &out); err != nil {
- t.Fatalf("Unmarshal error: %v", err)
- }
- if len(out.Nodes) != 2 {
- t.Fatalf("nodes = %d, want 2", len(out.Nodes))
- }
- if out.Nodes[0].URL != "https://example.com/a" {
- t.Fatalf("url mutated: %q", out.Nodes[0].URL)
- }
-}
-
-func TestSanitizeReturnsNilWhenValid(t *testing.T) {
- if got := Sanitize([]byte(`{"a":1}`)); got != nil {
- t.Fatalf("expected nil for clean json, got %q", got)
- }
-}
-
-func TestSanitizeEscapesNewlinesInsideStrings(t *testing.T) {
- raw := []byte(`{"examples":"第一行
-第二行","identity":"觀察者"}`)
- cleaned := Sanitize(raw)
- if cleaned == nil {
- t.Fatal("expected sanitized output")
- }
- var out struct {
- Examples string `json:"examples"`
- Identity string `json:"identity"`
- }
- if err := Unmarshal(raw, &out); err != nil {
- t.Fatalf("Unmarshal error: %v", err)
- }
- if out.Examples != "第一行\n第二行" {
- t.Fatalf("examples = %q", out.Examples)
- }
- if out.Identity != "觀察者" {
- t.Fatalf("identity = %q", out.Identity)
- }
-}
-
-func TestSanitizeKeepsDotsInsideStrings(t *testing.T) {
- raw := []byte(`{"note":"see ... and https://a.b/c", "list":[1, ...]}`)
- cleaned := Sanitize(raw)
- if cleaned == nil {
- t.Fatal("expected sanitized output")
- }
- var out struct {
- Note string `json:"note"`
- List []int `json:"list"`
- }
- if err := Unmarshal(raw, &out); err != nil {
- t.Fatalf("Unmarshal error: %v", err)
- }
- if out.Note != "see ... and https://a.b/c" {
- t.Fatalf("string content mutated: %q", out.Note)
- }
- if len(out.List) != 1 || out.List[0] != 1 {
- t.Fatalf("list = %v, want [1]", out.List)
- }
-}
diff --git a/old/backend/internal/library/mailer/smtp.go b/old/backend/internal/library/mailer/smtp.go
deleted file mode 100644
index d86cb40..0000000
--- a/old/backend/internal/library/mailer/smtp.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package mailer
-
-import (
- "context"
- "fmt"
- "net/smtp"
- "strings"
-
- "haixun-backend/internal/config"
-)
-
-type Mailer interface {
- Send(ctx context.Context, to, subject, text string) error
-}
-
-type SMTPMailer struct {
- host string
- port int
- username string
- password string
- from string
-}
-
-func NewSMTPMailer(cfg config.SMTPConf) *SMTPMailer {
- if strings.TrimSpace(cfg.Host) == "" || strings.TrimSpace(cfg.From) == "" {
- return nil
- }
- return &SMTPMailer{host: cfg.Host, port: cfg.Port, username: cfg.Username, password: cfg.Password, from: cfg.From}
-}
-
-func (m *SMTPMailer) Send(ctx context.Context, to, subject, text string) error {
- if m == nil {
- return nil
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
- addr := fmt.Sprintf("%s:%d", m.host, m.port)
- headers := []string{
- "From: " + m.from,
- "To: " + to,
- "Subject: " + subject,
- "MIME-Version: 1.0",
- "Content-Type: text/plain; charset=UTF-8",
- }
- msg := strings.Join(headers, "\r\n") + "\r\n\r\n" + text
- var auth smtp.Auth
- if m.username != "" || m.password != "" {
- auth = smtp.PlainAuth("", m.username, m.password, m.host)
- }
- return smtp.SendMail(addr, auth, m.from, []string{to}, []byte(msg))
-}
diff --git a/old/backend/internal/library/matrix/generate.go b/old/backend/internal/library/matrix/generate.go
deleted file mode 100644
index 3bbd571..0000000
--- a/old/backend/internal/library/matrix/generate.go
+++ /dev/null
@@ -1,288 +0,0 @@
-package matrix
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "regexp"
- "strings"
-
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/threadspost"
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-type Row struct {
- SortOrder int `json:"sort_order"`
- SearchTag string `json:"search_tag"`
- Angle string `json:"angle"`
- Hook string `json:"hook"`
- Text string `json:"text"`
- ReferenceNotes string `json:"reference_notes"`
- SourcePermalinks []string `json:"source_permalinks"`
- Rationale string `json:"rationale"`
- AiScore int `json:"ai_score"`
- FormulaScore int `json:"formula_score"`
- BrandFitScore int `json:"brand_fit_score"`
- RiskScore int `json:"risk_score"`
- SimilarityScore int `json:"similarity_score"`
- EngagementPotential int `json:"engagement_potential"`
- FreshnessScore int `json:"freshness_score"`
- ReviewSuggestion string `json:"review_suggestion"`
-}
-
-type GenerateResult struct {
- Rows []Row `json:"rows"`
-}
-
-type MaterialPost struct {
- SearchTag string
- Author string
- Text string
- Permalink string
- Priority string
-}
-
-type GenerateInput struct {
- Persona string
- TopicLabel string
- AudienceBrief string
- ProductBrief string
- Posts []MaterialPost
- Count int
-}
-
-var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
-
-func BuildUserPrompt(in GenerateInput) (string, error) {
- count := in.Count
- if count <= 0 {
- count = 5
- }
- personaBlock := ""
- if strings.TrimSpace(in.Persona) != "" {
- personaBlock = "人設與語氣:\n" + strings.TrimSpace(in.Persona) + "\n"
- }
- audience := strings.TrimSpace(in.AudienceBrief)
- if audience == "" {
- audience = "(未指定)"
- }
- product := strings.TrimSpace(in.ProductBrief)
- if product == "" {
- product = "(尚未填寫)"
- }
- topic := strings.TrimSpace(in.TopicLabel)
- if topic == "" {
- topic = "未指定"
- }
- return libprompt.MatrixPlacementUser(map[string]string{
- "persona_block": personaBlock,
- "topic_label": topic,
- "audience_line": audience,
- "product_brief": product,
- "post_count": fmt.Sprintf("%d", len(in.Posts)),
- "materials_block": buildMaterialsBlock(in.Posts),
- "count": fmt.Sprintf("%d", count),
- })
-}
-
-func buildMaterialsBlock(posts []MaterialPost) string {
- if len(posts) == 0 {
- return "(無素材)"
- }
- lines := make([]string, 0, len(posts))
- for i, post := range posts {
- lines = append(lines, fmt.Sprintf(
- "%d. [%s/%s] @%s\n%s\n連結:%s",
- i+1,
- strings.TrimSpace(post.Priority),
- strings.TrimSpace(post.SearchTag),
- strings.TrimSpace(post.Author),
- strings.TrimSpace(post.Text),
- strings.TrimSpace(post.Permalink),
- ))
- }
- return strings.Join(lines, "\n\n")
-}
-
-type TextGenerator interface {
- GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error)
-}
-
-// CopyMatrixGenerateRequest tunes token budget for multi-row JSON matrix output.
-func CopyMatrixGenerateRequest(base domai.GenerateRequest, count int) domai.GenerateRequest {
- if count <= 0 {
- count = 5
- }
- temp := 0.78
- tokens := 8192 + count*1024
- if tokens > 16384 {
- tokens = 16384
- }
- base.Temperature = &temp
- base.MaxTokens = &tokens
- return base
-}
-
-func MatrixRetryUserPrompt(count int) string {
- if count <= 0 {
- count = 5
- }
- return fmt.Sprintf(
- "上一則回覆的 JSON 不完整或無法解析。請只回傳單一 JSON 物件 {\"rows\":[...]},共 %d 篇,不要用 markdown 程式碼區塊、不要加說明文字。\n"+
- "每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale、ai_score、formula_score、brand_fit_score、risk_score、similarity_score、engagement_potential、freshness_score、review_suggestion。\n"+
- "reference_notes 與 rationale 各不超過 40 字;text 要有具體場景與故事感、每篇敘事節奏不同;text 一句一行、完全不用任何標點(emoji 可保留)。",
- count,
- )
-}
-
-// GenerateCopyOutput calls the LLM and parses matrix JSON, with one compact-json retry.
-func GenerateCopyOutput(
- ctx context.Context,
- ai TextGenerator,
- req domai.GenerateRequest,
- count int,
-) (GenerateResult, error) {
- genReq := CopyMatrixGenerateRequest(req, count)
- result, err := ai.GenerateText(ctx, genReq)
- if err != nil {
- return GenerateResult{}, err
- }
- parsed, parseErr := ParseGenerateOutput(result.Text)
- if parseErr == nil {
- if count > 0 && len(parsed.Rows) < count {
- parseErr = fmt.Errorf("matrix truncated: got %d rows, want %d", len(parsed.Rows), count)
- } else {
- return parsed, nil
- }
- }
- retryReq := CopyMatrixGenerateRequest(domai.GenerateRequest{
- Provider: req.Provider,
- Model: req.Model,
- Credential: req.Credential,
- System: req.System,
- Messages: append(
- append([]domai.Message{}, req.Messages...),
- domai.Message{Role: "assistant", Content: result.Text},
- domai.Message{Role: "user", Content: MatrixRetryUserPrompt(count)},
- ),
- }, count)
- retryResult, retryErr := ai.GenerateText(ctx, retryReq)
- if retryErr != nil {
- return GenerateResult{}, fmt.Errorf("matrix retry failed: %w (first parse: %v)", retryErr, parseErr)
- }
- retryParsed, retryParseErr := ParseGenerateOutput(retryResult.Text)
- if retryParseErr != nil {
- return GenerateResult{}, retryParseErr
- }
- if count > 0 && len(retryParsed.Rows) < count {
- return GenerateResult{}, fmt.Errorf("matrix truncated after retry: got %d rows, want %d", len(retryParsed.Rows), count)
- }
- return retryParsed, nil
-}
-
-func ParseGenerateOutput(raw string) (GenerateResult, error) {
- payload, err := extractJSONObject(raw)
- if err != nil {
- return GenerateResult{}, err
- }
- out, err := decodeGenerateResult(payload)
- if err == nil {
- return normalizeGenerateResult(out)
- }
- repaired, repairErr := repairTruncatedJSONObject(payload)
- if repairErr != nil {
- return GenerateResult{}, err
- }
- out, err = decodeGenerateResult(repaired)
- if err != nil {
- return GenerateResult{}, fmt.Errorf("parse matrix json: %w", err)
- }
- return normalizeGenerateResult(out)
-}
-
-func decodeGenerateResult(payload []byte) (GenerateResult, error) {
- var out GenerateResult
- if err := json.Unmarshal(payload, &out); err != nil {
- return GenerateResult{}, err
- }
- return out, nil
-}
-
-func normalizeGenerateResult(out GenerateResult) (GenerateResult, error) {
- if len(out.Rows) == 0 {
- return GenerateResult{}, fmt.Errorf("matrix rows missing")
- }
- for i := range out.Rows {
- out.Rows[i].Text = threadspost.FormatDraftText(out.Rows[i].Text)
- if out.Rows[i].Text == "" {
- return GenerateResult{}, fmt.Errorf("matrix row %d empty", i+1)
- }
- if out.Rows[i].SortOrder <= 0 {
- out.Rows[i].SortOrder = i + 1
- }
- }
- return out, nil
-}
-
-func extractJSONObject(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- if start < 0 {
- return nil, fmt.Errorf("matrix output missing json object")
- }
- slice := raw[start:]
- end := strings.LastIndex(slice, "}")
- if end <= 0 {
- return []byte(slice), nil
- }
- return []byte(slice[:end+1]), nil
-}
-
-func repairTruncatedJSONObject(payload []byte) ([]byte, error) {
- if len(payload) == 0 || payload[0] != '{' {
- return nil, fmt.Errorf("matrix output missing json object")
- }
- stack := make([]byte, 0, 8)
- inString := false
- escaped := false
- for _, b := range payload {
- if inString {
- if escaped {
- escaped = false
- continue
- }
- if b == '\\' {
- escaped = true
- continue
- }
- if b == '"' {
- inString = false
- }
- continue
- }
- switch b {
- case '"':
- inString = true
- case '{':
- stack = append(stack, '}')
- case '[':
- stack = append(stack, ']')
- case '}', ']':
- if len(stack) > 0 && stack[len(stack)-1] == b {
- stack = stack[:len(stack)-1]
- }
- }
- }
- repaired := append([]byte(nil), payload...)
- if inString {
- repaired = append(repaired, '"')
- }
- for i := len(stack) - 1; i >= 0; i-- {
- repaired = append(repaired, stack[i])
- }
- return repaired, nil
-}
diff --git a/old/backend/internal/library/matrix/generate_test.go b/old/backend/internal/library/matrix/generate_test.go
deleted file mode 100644
index 828ba4d..0000000
--- a/old/backend/internal/library/matrix/generate_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package matrix
-
-import (
- "context"
- "errors"
- "testing"
-
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-type stubTextGenerator struct {
- responses []string
- errs []error
- calls int
-}
-
-func (s *stubTextGenerator) GenerateText(_ context.Context, _ domai.GenerateRequest) (*domai.GenerateResult, error) {
- idx := s.calls
- s.calls++
- if idx < len(s.errs) && s.errs[idx] != nil {
- return nil, s.errs[idx]
- }
- if idx >= len(s.responses) {
- return nil, errors.New("no stub response")
- }
- return &domai.GenerateResult{Text: s.responses[idx]}, nil
-}
-
-func TestParseGenerateOutputCodeFenceWithPrefix(t *testing.T) {
- raw := "以下是結果:\n```json\n{\"rows\":[{\"sort_order\":1,\"text\":\"hello\",\"angle\":\"a\",\"hook\":\"h\",\"reference_notes\":\"n\",\"source_permalinks\":[],\"rationale\":\"r\"}]}\n```\n"
- got, err := ParseGenerateOutput(raw)
- if err != nil {
- t.Fatalf("ParseGenerateOutput() error = %v", err)
- }
- if len(got.Rows) != 1 || got.Rows[0].Text != "hello" {
- t.Fatalf("rows = %+v", got.Rows)
- }
-}
-
-func TestParseGenerateOutputRepairsTruncatedJSON(t *testing.T) {
- raw := `{"rows":[{"sort_order":1,"search_tag":"t","angle":"a","hook":"h","text":"主文","reference_notes":"n","source_permalinks":[],"rationale":"r"},{"sort_order":2,"search_tag":"t2","angle":"b","hook":"h2","text":"第二篇`
- got, err := ParseGenerateOutput(raw)
- if err != nil {
- t.Fatalf("ParseGenerateOutput() error = %v", err)
- }
- if len(got.Rows) != 1 {
- t.Fatalf("rows = %+v, want 1 recovered row", got.Rows)
- }
-}
-
-func TestCopyMatrixGenerateRequest_ScalesWithCount(t *testing.T) {
- req := CopyMatrixGenerateRequest(domai.GenerateRequest{}, 5)
- if req.MaxTokens == nil || *req.MaxTokens < 8192 {
- t.Fatalf("max_tokens = %+v, want >= 8192", req.MaxTokens)
- }
-}
-
-func TestGenerateCopyOutputRetriesOnParseFailure(t *testing.T) {
- bad := `{"rows":[{"sort_order":1,"text":"`
- good := `{"rows":[{"sort_order":1,"text":"ok","angle":"a","hook":"h","reference_notes":"n","source_permalinks":[],"rationale":"r"}]}`
- gen := &stubTextGenerator{responses: []string{bad, good}}
- got, err := GenerateCopyOutput(context.Background(), gen, domai.GenerateRequest{
- Messages: []domai.Message{{Role: "user", Content: "go"}},
- }, 1)
- if err != nil {
- t.Fatalf("GenerateCopyOutput() error = %v", err)
- }
- if len(got.Rows) != 1 || got.Rows[0].Text != "ok" {
- t.Fatalf("rows = %+v", got.Rows)
- }
- if gen.calls != 2 {
- t.Fatalf("calls = %d, want retry", gen.calls)
- }
-}
diff --git a/old/backend/internal/library/matrix/reference.go b/old/backend/internal/library/matrix/reference.go
deleted file mode 100644
index dc9a3e3..0000000
--- a/old/backend/internal/library/matrix/reference.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package matrix
-
-import (
- "strings"
-
- libweb "haixun-backend/internal/library/webpage"
-)
-
-func FormatCopyResearchMapBlockWithReferences(
- audience, goal string,
- questions, pillars, exclusions []string,
- knowledgeItems []string,
- researchItemsBlock string,
- referenceMarkdown string,
-) string {
- base := FormatCopyResearchMapBlock(
- audience,
- goal,
- questions,
- pillars,
- exclusions,
- knowledgeItems,
- )
- research := strings.TrimSpace(researchItemsBlock)
- if research != "" {
- if base == "" {
- base = "研究來源(Brave):\n" + research
- } else {
- base += "\n\n研究來源(Brave):\n" + research
- }
- }
- ref := strings.TrimSpace(referenceMarkdown)
- if ref == "" {
- return base
- }
- if base == "" {
- return "參考網頁(Markdown):\n" + ref
- }
- return base + "\n\n參考網頁(Markdown):\n" + ref
-}
-
-func BuildReferenceMarkdown(digests []libweb.Digest) string {
- return libweb.FormatMarkdownBlock(digests, libweb.DefaultMaxMarkdown)
-}
diff --git a/old/backend/internal/library/matrix/research_map.go b/old/backend/internal/library/matrix/research_map.go
deleted file mode 100644
index 6dda1af..0000000
--- a/old/backend/internal/library/matrix/research_map.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package matrix
-
-import "strings"
-
-// SelectKnowledgeItems prefers explicit selections, otherwise falls back to all items.
-func SelectKnowledgeItems(selected, all []string) []string {
- source := selected
- if len(source) == 0 {
- source = all
- }
- return dedupeKnowledgeLines(source)
-}
-
-// MergeKnowledgeItems combines explicit user selections without falling back to unselected items.
-func MergeKnowledgeItems(parts ...[]string) []string {
- merged := make([]string, 0)
- for _, part := range parts {
- merged = append(merged, part...)
- }
- return dedupeKnowledgeLines(merged)
-}
-
-func dedupeKnowledgeLines(lines []string) []string {
- out := make([]string, 0, len(lines))
- seen := map[string]struct{}{}
- for _, item := range lines {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- key := strings.ToLower(item)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, item)
- }
- return out
-}
-
-// FormatCopyResearchMapBlock renders a compact research-map block for LLM prompts.
-func FormatCopyResearchMapBlock(audience, goal string, questions, pillars, exclusions []string, knowledgeItems ...[]string) string {
- var b strings.Builder
- if audience = strings.TrimSpace(audience); audience != "" {
- b.WriteString("受眾:")
- b.WriteString(audience)
- b.WriteString("\n")
- }
- if goal = strings.TrimSpace(goal); goal != "" {
- b.WriteString("內容目標:")
- b.WriteString(goal)
- b.WriteString("\n")
- }
- if len(pillars) > 0 {
- b.WriteString("支柱:")
- b.WriteString(strings.Join(pillars, "、"))
- b.WriteString("\n")
- }
- if len(questions) > 0 {
- b.WriteString("受眾問題:")
- b.WriteString(strings.Join(questions, ";"))
- b.WriteString("\n")
- }
- if len(knowledgeItems) > 0 && len(knowledgeItems[0]) > 0 {
- b.WriteString("勾選延伸知識:")
- b.WriteString(strings.Join(knowledgeItems[0], ";"))
- b.WriteString("\n")
- }
- if len(exclusions) > 0 {
- b.WriteString("排除:")
- b.WriteString(strings.Join(exclusions, ";"))
- }
- return strings.TrimSpace(b.String())
-}
diff --git a/old/backend/internal/library/matrix/samples.go b/old/backend/internal/library/matrix/samples.go
deleted file mode 100644
index 87fa699..0000000
--- a/old/backend/internal/library/matrix/samples.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package matrix
-
-import (
- "fmt"
- "strings"
-)
-
-type ViralReplySample struct {
- Author string
- Text string
-}
-
-type ViralPostSample struct {
- Author string
- LikeCount int
- SearchTag string
- Text string
- Replies []ViralReplySample
-}
-
-func FormatViralSamples(posts []ViralPostSample) string {
- if len(posts) == 0 {
- return "(尚無海巡樣本;請依研究地圖、延伸知識與人設產出,不要編造參考貼文)"
- }
- var b strings.Builder
- limit := 8
- if len(posts) < limit {
- limit = len(posts)
- }
- for i := 0; i < limit; i++ {
- post := posts[i]
- b.WriteString(fmt.Sprintf("\n[%d] @%s · %d讚 · 標籤:%s\n", i+1, post.Author, post.LikeCount, post.SearchTag))
- text := strings.TrimSpace(post.Text)
- if len([]rune(text)) > 160 {
- text = string([]rune(text)[:160])
- }
- b.WriteString(text)
- b.WriteString("\n")
- if len(post.Replies) > 0 {
- b.WriteString(" 熱門留言:")
- for j, reply := range post.Replies {
- if j >= 3 {
- break
- }
- rt := strings.TrimSpace(reply.Text)
- if len([]rune(rt)) > 60 {
- rt = string([]rune(rt)[:60])
- }
- b.WriteString(fmt.Sprintf("\n - @%s: %s", reply.Author, rt))
- }
- b.WriteString("\n")
- }
- }
- return strings.TrimSpace(b.String())
-}
diff --git a/old/backend/internal/library/mongo/brand_scope.go b/old/backend/internal/library/mongo/brand_scope.go
deleted file mode 100644
index f76e163..0000000
--- a/old/backend/internal/library/mongo/brand_scope.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package mongo
-
-import (
- "strings"
-
- "go.mongodb.org/mongo-driver/bson"
-)
-
-// BrandScopeFilter matches documents keyed by brand_id or legacy persona_id.
-func BrandScopeFilter(brandID string) bson.M {
- id := strings.TrimSpace(brandID)
- return bson.M{"$or": []bson.M{
- {"brand_id": id},
- {"persona_id": id},
- }}
-}
-
-// ResolveBrandID returns brand_id when set, otherwise legacy persona_id.
-func ResolveBrandID(brandID, legacyPersonaID string) string {
- if trimmed := strings.TrimSpace(brandID); trimmed != "" {
- return trimmed
- }
- return strings.TrimSpace(legacyPersonaID)
-}
diff --git a/old/backend/internal/library/mongo/client.go b/old/backend/internal/library/mongo/client.go
deleted file mode 100644
index be3b101..0000000
--- a/old/backend/internal/library/mongo/client.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package mongo
-
-import (
- "context"
- "time"
-
- "haixun-backend/internal/config"
-
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type Client struct {
- raw *mongo.Client
- db *mongo.Database
-}
-
-func NewClient(ctx context.Context, conf config.MongoConf) (*Client, error) {
- if conf.URI == "" || conf.Database == "" {
- return nil, nil
- }
- timeout := time.Duration(conf.TimeoutSeconds) * time.Second
- if timeout <= 0 {
- timeout = 10 * time.Second
- }
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- raw, err := mongo.Connect(ctx, options.Client().ApplyURI(conf.URI))
- if err != nil {
- return nil, err
- }
- if err := raw.Ping(ctx, nil); err != nil {
- return nil, err
- }
- return &Client{raw: raw, db: raw.Database(conf.Database)}, nil
-}
-
-func (c *Client) Database() *mongo.Database {
- if c == nil {
- return nil
- }
- return c.db
-}
-
-func (c *Client) Close(ctx context.Context) error {
- if c == nil || c.raw == nil {
- return nil
- }
- return c.raw.Disconnect(ctx)
-}
diff --git a/old/backend/internal/library/mongo/ensure_index.go b/old/backend/internal/library/mongo/ensure_index.go
deleted file mode 100644
index 6cd9592..0000000
--- a/old/backend/internal/library/mongo/ensure_index.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package mongo
-
-import (
- "context"
- "errors"
- "strconv"
- "strings"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
-)
-
-// MongoDB server error codes for index conflicts.
-const (
- indexOptionsConflictCode = 85 // IndexOptionsConflict
- indexKeySpecsConflictCode = 86 // IndexKeySpecsConflict
-)
-
-// EnsureIndexes creates the requested indexes, recovering from conflicts caused
-// by indexes that an earlier schema version created with the same name but
-// different options (e.g. adding a partialFilterExpression during the
-// persona -> brand migration). On a conflict it drops the stale index by its
-// generated name and recreates it with the requested options, so startup does
-// not panic on environments that still hold the legacy index.
-func EnsureIndexes(ctx context.Context, coll *mongo.Collection, models []mongo.IndexModel) error {
- if coll == nil {
- return nil
- }
- for _, model := range models {
- if _, err := coll.Indexes().CreateOne(ctx, model); err != nil {
- if !isIndexConflict(err) {
- return err
- }
- name := indexName(model)
- if name == "" {
- return err
- }
- if _, dropErr := coll.Indexes().DropOne(ctx, name); dropErr != nil {
- return err
- }
- if _, retryErr := coll.Indexes().CreateOne(ctx, model); retryErr != nil {
- return retryErr
- }
- }
- }
- return nil
-}
-
-func isIndexConflict(err error) bool {
- var serverErr mongo.ServerError
- if errors.As(err, &serverErr) {
- return serverErr.HasErrorCode(indexOptionsConflictCode) ||
- serverErr.HasErrorCode(indexKeySpecsConflictCode)
- }
- return false
-}
-
-// indexName reproduces MongoDB's default index name (key_direction pairs joined
-// by underscores) so a conflicting index can be dropped by name.
-func indexName(model mongo.IndexModel) string {
- if model.Options != nil && model.Options.Name != nil {
- return *model.Options.Name
- }
- keys, ok := model.Keys.(bson.D)
- if !ok {
- return ""
- }
- parts := make([]string, 0, len(keys)*2)
- for _, e := range keys {
- parts = append(parts, e.Key, indexValueToken(e.Value))
- }
- return strings.Join(parts, "_")
-}
-
-func indexValueToken(v any) string {
- switch t := v.(type) {
- case int:
- return strconv.Itoa(t)
- case int32:
- return strconv.Itoa(int(t))
- case int64:
- return strconv.FormatInt(t, 10)
- case float64:
- return strconv.FormatInt(int64(t), 10)
- case string:
- return t
- default:
- return ""
- }
-}
diff --git a/old/backend/internal/library/outreach/draft_context.go b/old/backend/internal/library/outreach/draft_context.go
deleted file mode 100644
index eccea44..0000000
--- a/old/backend/internal/library/outreach/draft_context.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package outreach
-
-import (
- "fmt"
- "strings"
-
- libkg "haixun-backend/internal/library/knowledge"
- libplacement "haixun-backend/internal/library/placement"
- brandentity "haixun-backend/internal/model/brand/domain/entity"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
-)
-
-func FindGraphNode(nodes []libkg.Node, nodeID string) *libkg.Node {
- nodeID = strings.TrimSpace(nodeID)
- if nodeID == "" {
- return nil
- }
- for i := range nodes {
- if nodes[i].ID == nodeID {
- return &nodes[i]
- }
- }
- return nil
-}
-
-func BuildPlacementReason(post *scanpostusecase.ScanPostSummary) string {
- if post == nil {
- return ""
- }
- parts := []string{}
- if post.Priority == "gold" {
- parts = append(parts, "雙軌命中(相關+近期)")
- } else if post.Priority == "recent" {
- parts = append(parts, "近期軌命中")
- }
- if post.SolvedByProduct {
- parts = append(parts, "產品可解此需求")
- }
- if post.ProductFitScore > 0 {
- parts = append(parts, fmt.Sprintf("產品匹配 %d", post.ProductFitScore))
- }
- return strings.Join(parts, ";")
-}
-
-func MergePlacementReason(base string, node *libkg.Node, post *scanpostusecase.ScanPostSummary) string {
- parts := []string{}
- if strings.TrimSpace(base) != "" {
- parts = append(parts, strings.TrimSpace(base))
- }
- if node != nil {
- if strings.TrimSpace(node.PlacementValue) != "" {
- parts = append(parts, "置入價值 "+strings.TrimSpace(node.PlacementValue))
- }
- if node.ProductFitScore > 0 && (post == nil || post.ProductFitScore == 0) {
- parts = append(parts, fmt.Sprintf("節點產品匹配 %d", node.ProductFitScore))
- }
- }
- return strings.Join(parts, ";")
-}
-
-func ProductBriefForBrand(brand *branddomain.BrandSummary, productID, searchTag, postText string) string {
- if brand == nil {
- return ""
- }
- if product := PickProductForOutreach(brand, productID, searchTag, postText); product != nil {
- merged := libplacement.BuildMergedProductContext(brand.DisplayName, product.ProductContext, product.Label)
- merged = libplacement.ApplyPlacementURL(merged, product.PlacementURL)
- if pb := libplacement.ProductBriefFromContext(merged); pb != "" {
- return pb
- }
- }
- if pb := libplacement.ProductBriefFromContext(brand.ProductContext); pb != "" {
- return pb
- }
- return strings.TrimSpace(brand.ProductBrief)
-}
-
-func PickProductForOutreach(brand *branddomain.BrandSummary, preferredProductID, searchTag, postText string) *branddomain.ProductSummary {
- if brand == nil || len(brand.Products) == 0 {
- return nil
- }
- entities := make([]brandentity.Product, len(brand.Products))
- for i := range brand.Products {
- entities[i] = brandentity.Product{
- ID: brand.Products[i].ID,
- Label: brand.Products[i].Label,
- ProductContext: brand.Products[i].ProductContext,
- MatchTags: append([]string(nil), brand.Products[i].MatchTags...),
- }
- }
- if picked := libplacement.RecommendProductForPost(entities, searchTag, postText, preferredProductID); picked != nil {
- for i := range brand.Products {
- if brand.Products[i].ID == picked.ID {
- return &brand.Products[i]
- }
- }
- }
- if id := strings.TrimSpace(preferredProductID); id != "" {
- for i := range brand.Products {
- if brand.Products[i].ID == id {
- return &brand.Products[i]
- }
- }
- }
- return &brand.Products[0]
-}
diff --git a/old/backend/internal/library/outreach/draft_format.go b/old/backend/internal/library/outreach/draft_format.go
deleted file mode 100644
index d797df1..0000000
--- a/old/backend/internal/library/outreach/draft_format.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package outreach
-
-import (
- "strings"
-)
-
-func NormalizeDraftText(text string) string {
- text = strings.ReplaceAll(text, "\r\n", "\n")
- text = strings.ReplaceAll(text, "\r", "\n")
- text = strings.TrimSpace(text)
- text = humanizeDraftLineBreaks(text)
- return trimDraftText(text)
-}
-
-func humanizeDraftLineBreaks(text string) string {
- if text == "" || strings.Contains(text, "\n") {
- return text
- }
- if len([]rune(text)) < 40 {
- return text
- }
-
- sentences := splitSentences(text)
- if len(sentences) <= 1 {
- return text
- }
-
- paragraphs := make([]string, 0, (len(sentences)+1)/2)
- for i := 0; i < len(sentences); {
- end := i + 2
- if end > len(sentences) {
- end = len(sentences)
- }
- paragraphs = append(paragraphs, strings.Join(sentences[i:end], ""))
- i = end
- }
- return strings.Join(paragraphs, "\n\n")
-}
-
-func splitSentences(text string) []string {
- runes := []rune(text)
- if len(runes) == 0 {
- return nil
- }
- out := make([]string, 0, 4)
- var current strings.Builder
- for i := 0; i < len(runes); i++ {
- ch := runes[i]
- current.WriteRune(ch)
- if !isSentenceEnd(ch) {
- continue
- }
- // Keep ellipsis / repeated punctuation attached to the same sentence.
- for i+1 < len(runes) && isSentenceEnd(runes[i+1]) {
- i++
- current.WriteRune(runes[i])
- }
- if sentence := strings.TrimSpace(current.String()); sentence != "" {
- out = append(out, sentence)
- }
- current.Reset()
- }
- if tail := strings.TrimSpace(current.String()); tail != "" {
- out = append(out, tail)
- }
- return out
-}
-
-func isSentenceEnd(ch rune) bool {
- switch ch {
- case '。', '!', '?', '!', '?', '…':
- return true
- default:
- return false
- }
-}
diff --git a/old/backend/internal/library/outreach/draft_format_test.go b/old/backend/internal/library/outreach/draft_format_test.go
deleted file mode 100644
index 5a431de..0000000
--- a/old/backend/internal/library/outreach/draft_format_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package outreach
-
-import "testing"
-
-func TestNormalizeDraftTextPreservesNewlines(t *testing.T) {
- raw := "第一段。\n\n第二段。"
- got := NormalizeDraftText(raw)
- if got != raw {
- t.Fatalf("got %q, want %q", got, raw)
- }
-}
-
-func TestHumanizeDraftLineBreaks(t *testing.T) {
- raw := "我懂你的感受。之前我們家也遇過類似狀況。後來換了無香的才比較舒服。希望你有找到適合的。"
- got := NormalizeDraftText(raw)
- if !containsNewline(got) {
- t.Fatalf("expected paragraph breaks, got %q", got)
- }
-}
-
-func TestNormalizeDraftTextShortSingleParagraph(t *testing.T) {
- raw := "加油,會好的。"
- got := NormalizeDraftText(raw)
- if got != raw {
- t.Fatalf("got %q, want %q", got, raw)
- }
-}
-
-func containsNewline(s string) bool {
- for i := 0; i < len(s); i++ {
- if s[i] == '\n' {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/outreach/generate.go b/old/backend/internal/library/outreach/generate.go
deleted file mode 100644
index 1c5e2d0..0000000
--- a/old/backend/internal/library/outreach/generate.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package outreach
-
-import (
- "encoding/json"
- "fmt"
- "regexp"
- "strings"
-
- libprompt "haixun-backend/internal/library/prompt"
-)
-
-const maxChars = 500
-
-type Draft struct {
- Text string `json:"text"`
- Angle string `json:"angle"`
- Rationale string `json:"rationale"`
-}
-
-type GenerateResult struct {
- Relevance float64 `json:"relevance"`
- Reason string `json:"reason"`
- Drafts []Draft `json:"drafts"`
-}
-
-type GenerateInput struct {
- Persona string
- TopicLabel string
- AudienceBrief string
- ProductBrief string
- PlacementReason string
- TargetText string
- AuthorName string
- Count int
- Regenerate bool
- PreviousDraftsHint string
-}
-
-var codeFenceRE = regexp.MustCompile(`(?s)^` + "```(?:json)?\\s*(.*?)\\s*" + "```$")
-
-func BuildUserPrompt(in GenerateInput) (string, error) {
- count := in.Count
- if count <= 0 {
- count = 2
- }
- personaBlock := ""
- if strings.TrimSpace(in.Persona) != "" {
- personaBlock = "人設與語氣:\n" + strings.TrimSpace(in.Persona) + "\n"
- }
- audienceLine := ""
- if strings.TrimSpace(in.AudienceBrief) != "" {
- audienceLine = "受眾與情境:" + strings.TrimSpace(in.AudienceBrief)
- }
- productBrief := strings.TrimSpace(in.ProductBrief)
- if productBrief == "" {
- productBrief = "(尚未填寫品牌與產品,請先給實用建議,不要捏造品牌)"
- }
- reasonLine := ""
- if strings.TrimSpace(in.PlacementReason) != "" {
- reasonLine = "為何適合留言:" + strings.TrimSpace(in.PlacementReason)
- }
- topic := strings.TrimSpace(in.TopicLabel)
- if topic == "" {
- topic = "未指定"
- }
- author := strings.TrimSpace(in.AuthorName)
- if author == "" {
- author = "匿名"
- }
- regenerateLine := ""
- if in.Regenerate || strings.TrimSpace(in.PreviousDraftsHint) != "" {
- regenerateLine = "\n【重新生成】請產出與舊版明顯不同的新草稿:換開頭、換切角、換句式與情境細節,不要複製舊文案。"
- if hint := strings.TrimSpace(in.PreviousDraftsHint); hint != "" {
- regenerateLine += "\n" + hint
- }
- }
- return libprompt.OutreachPlacementUser(map[string]string{
- "persona_block": personaBlock,
- "topic_label": topic,
- "audience_line": audienceLine,
- "product_brief": productBrief,
- "placement_reason_line": reasonLine,
- "author_name": author,
- "target_text": strings.TrimSpace(in.TargetText),
- "regenerate_line": regenerateLine,
- "count": fmt.Sprintf("%d", count),
- })
-}
-
-func ParseGenerateOutput(raw string) (GenerateResult, error) {
- payload, err := extractJSONObject(raw)
- if err != nil {
- return GenerateResult{}, err
- }
- var out GenerateResult
- if err := json.Unmarshal(payload, &out); err != nil {
- return GenerateResult{}, fmt.Errorf("parse outreach json: %w", err)
- }
- if len(out.Drafts) == 0 {
- return GenerateResult{}, fmt.Errorf("outreach drafts missing")
- }
- for i := range out.Drafts {
- out.Drafts[i].Text = NormalizeDraftText(out.Drafts[i].Text)
- if out.Drafts[i].Text == "" {
- return GenerateResult{}, fmt.Errorf("outreach draft %d empty", i+1)
- }
- }
- if out.Relevance < 0 {
- out.Relevance = 0
- }
- if out.Relevance > 1 {
- out.Relevance = 1
- }
- out.Reason = strings.TrimSpace(out.Reason)
- return out, nil
-}
-
-func trimDraftText(text string) string {
- text = strings.TrimSpace(text)
- runes := []rune(text)
- if len(runes) > maxChars {
- return string(runes[:maxChars])
- }
- return text
-}
-
-func extractJSONObject(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("outreach output missing json object")
- }
- return []byte(raw[start : end+1]), nil
-}
diff --git a/old/backend/internal/library/outreach/generate_draft.go b/old/backend/internal/library/outreach/generate_draft.go
deleted file mode 100644
index fea7e67..0000000
--- a/old/backend/internal/library/outreach/generate_draft.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package outreach
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libprompt "haixun-backend/internal/library/prompt"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateDraftRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- ScanPostID string
- Count int
- VoicePersonaID string
- ProductID string
- Regenerate bool
-}
-
-type GenerateDraftDeps struct {
- Brand branddomain.UseCase
- Persona personadomain.UseCase
- ScanPost scanpostusecase.UseCase
- KnowledgeGraph kgdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- AI aiusecase.UseCase
- OutreachDraft outreachusecase.UseCase
-}
-
-func GenerateDraft(ctx context.Context, deps GenerateDraftDeps, req GenerateDraftRequest) (*outreachusecase.DraftSummary, error) {
- scanPostID := strings.TrimSpace(req.ScanPostID)
- count := req.Count
- if count <= 0 {
- count = 2
- }
- if count > 4 {
- count = 4
- }
- if scanPostID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
- }
-
- brand, err := deps.Brand.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID)
- if err != nil {
- return nil, err
- }
- voiceID := strings.TrimSpace(req.VoicePersonaID)
- if voiceID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
- }
- vp, err := deps.Persona.Get(ctx, req.TenantID, req.OwnerUID, voiceID)
- if err != nil {
- return nil, err
- }
- voicePersona := FormatVoicePersonaBlock(vp)
- if voicePersona == "" {
- return nil, app.For(code.Persona).InputMissingRequired("請先在人設頁填寫人設描述或完成 8D 風格分析")
- }
- preferredProductID := strings.TrimSpace(req.ProductID)
- if preferredProductID == "" {
- preferredProductID = strings.TrimSpace(brand.ProductID)
- }
- post, err := deps.ScanPost.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID, scanPostID)
- if err != nil {
- return nil, err
- }
- pickedProduct := PickProductForOutreach(brand, preferredProductID, post.SearchTag, post.Text)
- productID := preferredProductID
- if pickedProduct != nil {
- productID = pickedProduct.ID
- }
-
- topicLabel := strings.TrimSpace(post.SearchTag)
- placementReason := BuildPlacementReason(post)
- if graph, graphErr := deps.KnowledgeGraph.Get(ctx, req.TenantID, req.OwnerUID, req.BrandID); graphErr == nil && graph != nil {
- if node := FindGraphNode(graph.Nodes, post.GraphNodeID); node != nil {
- if strings.TrimSpace(node.Label) != "" {
- topicLabel = strings.TrimSpace(node.Label)
- }
- placementReason = MergePlacementReason(placementReason, node, post)
- }
- }
-
- regenerate := req.Regenerate
- previousHint := ""
- if latest, latestErr := deps.OutreachDraft.GetLatestByScanPost(
- ctx, req.TenantID, req.OwnerUID, req.BrandID, strings.TrimSpace(req.TopicID), scanPostID,
- ); latestErr == nil && latest != nil && len(latest.Drafts) > 0 {
- regenerate = true
- previousHint = FormatPreviousDraftsHint(latest)
- }
-
- userPrompt, err := BuildUserPrompt(GenerateInput{
- Persona: voicePersona,
- TopicLabel: topicLabel,
- AudienceBrief: brand.TargetAudience,
- ProductBrief: ProductBriefForBrand(brand, productID, post.SearchTag, post.Text),
- PlacementReason: placementReason,
- TargetText: post.Text,
- AuthorName: post.Author,
- Count: count,
- Regenerate: regenerate,
- PreviousDraftsHint: previousHint,
- })
- if err != nil {
- return nil, app.For(code.AI).SysInternal("outreach user prompt load failed")
- }
-
- systemPrompt, err := libprompt.OutreachPlacementSystem()
- if err != nil {
- return nil, app.For(code.AI).SysInternal("outreach system prompt load failed")
- }
- systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n【最高優先】以下人設與語氣是硬約束,每則留言都必須像這個角色親自回覆,不得變成通用網友口吻:\n" + strings.TrimSpace(voicePersona)
-
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, req.TenantID, req.OwnerUID)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- temp := 0.88
- result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- Temperature: &temp,
- })
- if err != nil {
- return nil, err
- }
-
- parsed, err := ParseGenerateOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("獲客留言 LLM 回傳無法解析:" + err.Error())
- }
-
- draftItems := make([]outreachusecase.DraftItem, 0, len(parsed.Drafts))
- for _, item := range parsed.Drafts {
- draftItems = append(draftItems, outreachusecase.DraftItem{
- Text: item.Text,
- Angle: item.Angle,
- Rationale: item.Rationale,
- })
- }
- saved, err := deps.OutreachDraft.Create(ctx, outreachusecase.CreateRequest{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- BrandID: req.BrandID,
- TopicID: strings.TrimSpace(req.TopicID),
- ScanPostID: scanPostID,
- Relevance: parsed.Relevance,
- Reason: parsed.Reason,
- Drafts: draftItems,
- })
- if err != nil {
- return nil, err
- }
- _, _ = deps.ScanPost.UpdateOutreach(ctx, scanpostusecase.UpdateOutreachRequest{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- BrandID: req.BrandID,
- PostID: scanPostID,
- Status: scanpostentity.OutreachStatusDrafted,
- })
- return saved, nil
-}
diff --git a/old/backend/internal/library/outreach/generate_test.go b/old/backend/internal/library/outreach/generate_test.go
deleted file mode 100644
index 3840a89..0000000
--- a/old/backend/internal/library/outreach/generate_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package outreach
-
-import "testing"
-
-func TestParseGenerateOutput(t *testing.T) {
- raw := `{"relevance":0.85,"reason":"對方在問敏感肌保養","drafts":[{"text":"我之前也這樣…","angle":"共情","rationale":"先回應情緒"}]}`
- got, err := ParseGenerateOutput(raw)
- if err != nil {
- t.Fatalf("ParseGenerateOutput() error = %v", err)
- }
- if got.Relevance != 0.85 {
- t.Fatalf("relevance = %v, want 0.85", got.Relevance)
- }
- if len(got.Drafts) != 1 || got.Drafts[0].Text == "" {
- t.Fatalf("drafts = %+v", got.Drafts)
- }
-}
-
-func TestParseGenerateOutputCodeFence(t *testing.T) {
- raw := "```json\n{\"relevance\":1.2,\"reason\":\"ok\",\"drafts\":[{\"text\":\"hi\",\"angle\":\"a\",\"rationale\":\"b\"}]}\n```"
- got, err := ParseGenerateOutput(raw)
- if err != nil {
- t.Fatalf("ParseGenerateOutput() error = %v", err)
- }
- if got.Relevance != 1 {
- t.Fatalf("relevance clamped = %v, want 1", got.Relevance)
- }
-}
diff --git a/old/backend/internal/library/outreach/persona_voice.go b/old/backend/internal/library/outreach/persona_voice.go
deleted file mode 100644
index 292571d..0000000
--- a/old/backend/internal/library/outreach/persona_voice.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package outreach
-
-import (
- "strings"
-
- "haixun-backend/internal/library/style8d"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
-)
-
-// FormatVoicePersonaBlock builds the LLM persona block from a full persona profile.
-func FormatVoicePersonaBlock(vp *personadomain.PersonaSummary) string {
- if vp == nil {
- return ""
- }
- block := style8d.ResolvePersonaBlock(vp.Persona, vp.StyleProfile, vp.Brief)
- if block == "" {
- return ""
- }
- extras := make([]string, 0, 3)
- if name := strings.TrimSpace(vp.DisplayName); name != "" {
- extras = append(extras, "角色名稱:"+name)
- }
- if goals := strings.TrimSpace(vp.Goals); goals != "" {
- extras = append(extras, "回覆目標:"+goals)
- }
- if benchmark := strings.TrimSpace(vp.StyleBenchmark); benchmark != "" {
- extras = append(extras, "語氣參考:"+benchmark)
- }
- if len(extras) == 0 {
- return block
- }
- return strings.TrimSpace(block + "\n\n" + strings.Join(extras, "\n"))
-}
diff --git a/old/backend/internal/library/outreach/persona_voice_test.go b/old/backend/internal/library/outreach/persona_voice_test.go
deleted file mode 100644
index cc7f763..0000000
--- a/old/backend/internal/library/outreach/persona_voice_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package outreach
-
-import (
- "strings"
- "testing"
-
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
-)
-
-func TestFormatVoicePersonaBlockUsesPersonaAndGoals(t *testing.T) {
- block := FormatVoicePersonaBlock(&personadomain.PersonaSummary{
- DisplayName: "小安",
- Persona: "溫柔媽媽,說話慢一點、會先共情",
- Goals: "讓對方覺得被理解,再輕鬆分享經驗",
- })
- if !strings.Contains(block, "溫柔媽媽") {
- t.Fatalf("expected persona text, got %q", block)
- }
- if !strings.Contains(block, "小安") {
- t.Fatalf("expected display name, got %q", block)
- }
- if !strings.Contains(block, "讓對方覺得被理解") {
- t.Fatalf("expected goals, got %q", block)
- }
-}
-
-func TestFormatVoicePersonaBlockEmpty(t *testing.T) {
- if FormatVoicePersonaBlock(nil) != "" {
- t.Fatal("expected empty block for nil persona")
- }
- if FormatVoicePersonaBlock(&personadomain.PersonaSummary{}) != "" {
- t.Fatal("expected empty block for blank persona")
- }
-}
diff --git a/old/backend/internal/library/outreach/previous_drafts.go b/old/backend/internal/library/outreach/previous_drafts.go
deleted file mode 100644
index f9f577d..0000000
--- a/old/backend/internal/library/outreach/previous_drafts.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package outreach
-
-import (
- "fmt"
- "strings"
-
- outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
-)
-
-func FormatPreviousDraftsHint(latest *outreachusecase.DraftSummary) string {
- if latest == nil || len(latest.Drafts) == 0 {
- return ""
- }
- lines := []string{"上一版草稿(請避開相同寫法):"}
- for i, item := range latest.Drafts {
- angle := strings.TrimSpace(item.Angle)
- if angle == "" {
- angle = fmt.Sprintf("版本%d", i+1)
- }
- lines = append(lines, fmt.Sprintf("- %s:%s", angle, truncateRunes(item.Text, 140)))
- }
- return strings.Join(lines, "\n")
-}
-
-func truncateRunes(text string, max int) string {
- text = strings.TrimSpace(text)
- runes := []rune(text)
- if len(runes) <= max {
- return text
- }
- return string(runes[:max]) + "…"
-}
diff --git a/old/backend/internal/library/outreach/previous_drafts_test.go b/old/backend/internal/library/outreach/previous_drafts_test.go
deleted file mode 100644
index c5bb007..0000000
--- a/old/backend/internal/library/outreach/previous_drafts_test.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package outreach
-
-import (
- "strings"
- "testing"
-
- outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
-)
-
-func TestFormatPreviousDraftsHint(t *testing.T) {
- hint := FormatPreviousDraftsHint(&outreachusecase.DraftSummary{
- Drafts: []outreachusecase.DraftItem{
- {Angle: "共情", Text: "我懂你的感受"},
- },
- })
- if !strings.Contains(hint, "上一版草稿") || !strings.Contains(hint, "共情") {
- t.Fatalf("hint = %q", hint)
- }
-}
diff --git a/old/backend/internal/library/ownpost/post_formula.go b/old/backend/internal/library/ownpost/post_formula.go
deleted file mode 100644
index b74ad64..0000000
--- a/old/backend/internal/library/ownpost/post_formula.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package ownpost
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "regexp"
- "strings"
-
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-var formulaFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-type PostFormulaInput struct {
- AccountName string
- PersonaBlock string
- PostText string
- TopicTag string
- MediaType string
- LikeCount int
- ReplyCount int
- Views int
- RepostCount int
- QuoteCount int
- Shares int
- Timestamp string
-}
-
-type PostFormulaReview struct {
- Summary string `json:"summary"`
- Wins []string `json:"wins"`
- Improvements []string `json:"improvements"`
- Formula string `json:"formula"`
- PostTemplate string `json:"post_template"`
- HookPattern string `json:"hook_pattern"`
- Structure string `json:"structure"`
- ReplicationTips []string `json:"replication_tips"`
- Avoid []string `json:"avoid"`
-}
-
-func BuildPostFormulaPrompt(in PostFormulaInput) (system string, user string) {
- personaBlock := strings.TrimSpace(in.PersonaBlock)
- if personaBlock == "" {
- personaBlock = "(未指定人設,請依貼文本身語氣分析)"
- }
- postText := strings.TrimSpace(in.PostText)
- if postText == "" {
- postText = "(貼文內容未提供)"
- }
-
- system = strings.TrimSpace(`
-你是 Threads 內容策略教練。任務是針對「已發布的單篇貼文」做誠實覆盤:好的要提炼成可複製公式,壞的要指出問題並給具體改善做法。不要重寫整篇貼文。
-
-規則:
-- 用台灣繁體中文。
-- 必須同時寫優點與缺點;依成效數據判斷,成效普通或偏差時更要直言問題。
-- 分析要具體、可執行,禁止空泛稱讚或空泛批評。
-- formula:把「做對的部分」整理成步驟化公式(例:Hook→情境→轉折→觀點→收尾/互動)。
-- post_template:依 formula 產出可複製的發文骨架,用 [括號] 標示可替換欄位,讓使用者下次填空就能產文。
-- improvements:每點格式固定為「問題:…|改善:…」,改善必須是可立刻執行的動作(改哪一句、加什麼、刪什麼、換什麼角度)。
-- 只輸出一個 JSON 物件,不要 markdown,欄位:
- summary, wins, improvements, formula, post_template, hook_pattern, structure, replication_tips, avoid
-- wins / improvements / replication_tips / avoid 各 2~5 點字串陣列。`)
-
- var b strings.Builder
- b.WriteString("【1. 人設】\n")
- b.WriteString(personaBlock)
- b.WriteString("\n\n帳號:")
- b.WriteString(strings.TrimSpace(in.AccountName))
- b.WriteString("\n\n【2. 貼文內容】\n")
- b.WriteString(postText)
- if tag := strings.TrimSpace(in.TopicTag); tag != "" {
- b.WriteString("\n話題標籤:#")
- b.WriteString(tag)
- }
- if mt := strings.TrimSpace(in.MediaType); mt != "" {
- b.WriteString("\n媒體類型:")
- b.WriteString(mt)
- }
- if ts := strings.TrimSpace(in.Timestamp); ts != "" {
- b.WriteString("\n發布時間:")
- b.WriteString(ts)
- }
- b.WriteString("\n\n【3. 成效數據】\n")
- b.WriteString(fmt.Sprintf("讚 %d|回覆 %d|瀏覽 %d|轉發 %d|引用 %d|分享 %d",
- in.LikeCount, in.ReplyCount, in.Views, in.RepostCount, in.QuoteCount, in.Shares))
- b.WriteString("\n\n請誠實覆盤:做對了什麼、哪裡不好、怎麼改,並把做對的部分整理成下次可複製的公式與發文模板 JSON。")
- user = b.String()
- return system, user
-}
-
-func GeneratePostFormula(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in PostFormulaInput) (*PostFormulaReview, error) {
- system, user := BuildPostFormulaPrompt(in)
- temp := 0.65
- req.System = system
- req.Messages = []domai.Message{{Role: "user", Content: user}}
- req.Temperature = &temp
- out, err := ai.GenerateText(ctx, req)
- if err != nil {
- return nil, err
- }
- review, err := ParsePostFormulaOutput(out.Text)
- if err != nil {
- return nil, err
- }
- return review, nil
-}
-
-func ParsePostFormulaOutput(raw string) (*PostFormulaReview, error) {
- payload, err := extractFormulaJSONObject(raw)
- if err != nil {
- return nil, err
- }
- var review PostFormulaReview
- if err := json.Unmarshal(payload, &review); err != nil {
- return nil, fmt.Errorf("parse post formula json: %w", err)
- }
- review.Summary = strings.TrimSpace(review.Summary)
- review.Formula = strings.TrimSpace(review.Formula)
- review.PostTemplate = strings.TrimSpace(review.PostTemplate)
- review.HookPattern = strings.TrimSpace(review.HookPattern)
- review.Structure = strings.TrimSpace(review.Structure)
- review.Wins = trimStringList(review.Wins)
- review.Improvements = trimStringList(review.Improvements)
- review.ReplicationTips = trimStringList(review.ReplicationTips)
- review.Avoid = trimStringList(review.Avoid)
- if review.Summary == "" && review.Formula == "" && review.PostTemplate == "" {
- return nil, fmt.Errorf("AI 未回傳有效覆盤內容")
- }
- return &review, nil
-}
-
-func trimStringList(items []string) []string {
- if len(items) == 0 {
- return nil
- }
- out := make([]string, 0, len(items))
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item != "" {
- out = append(out, item)
- }
- }
- if len(out) == 0 {
- return nil
- }
- return out
-}
-
-func extractFormulaJSONObject(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := formulaFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("formula output missing json object")
- }
- return []byte(raw[start : end+1]), nil
-}
diff --git a/old/backend/internal/library/ownpost/post_formula_test.go b/old/backend/internal/library/ownpost/post_formula_test.go
deleted file mode 100644
index 5575e60..0000000
--- a/old/backend/internal/library/ownpost/post_formula_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package ownpost
-
-import "testing"
-
-func TestParsePostFormulaOutputIncludesImprovementsAndTemplate(t *testing.T) {
- raw := `{
- "summary": "整體互動偏低,但開場有吸引力。",
- "wins": ["開場直接點痛點"],
- "improvements": ["問題:收尾沒有問句|改善:最後加一句開放式提問引導留言"],
- "formula": "Hook→情境→觀點→互動問句",
- "post_template": "[痛點一句]\n[親身情境]\n[你的觀點]\n[問句收尾]",
- "hook_pattern": "先丟痛點再現身說法",
- "structure": "4 段,每段 1-2 句",
- "replication_tips": ["下次同主題先寫 Hook 再補故事"],
- "avoid": ["不要一次講太多資訊"]
-}`
- review, err := ParsePostFormulaOutput(raw)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- if len(review.Improvements) != 1 {
- t.Fatalf("improvements: got %d", len(review.Improvements))
- }
- if review.PostTemplate == "" {
- t.Fatal("expected post_template")
- }
-}
-
-func TestTrimStringListDropsEmpty(t *testing.T) {
- got := trimStringList([]string{" a ", "", "b"})
- if len(got) != 2 || got[0] != "a" || got[1] != "b" {
- t.Fatalf("unexpected: %#v", got)
- }
-}
diff --git a/old/backend/internal/library/ownpost/reply_draft.go b/old/backend/internal/library/ownpost/reply_draft.go
deleted file mode 100644
index 8ccf529..0000000
--- a/old/backend/internal/library/ownpost/reply_draft.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package ownpost
-
-import (
- "context"
- "fmt"
- "regexp"
- "strings"
- "unicode/utf8"
-
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-type ReplyDraftInput struct {
- AccountName string
- PersonaBlock string
- PostText string
- ReplyText string
- ReplyToID string
- ThreadPostText string
- ParentReplyText string
-}
-
-var (
- wrapQuoteRE = regexp.MustCompile(`^["'「『](.*)["'」』]$`)
- multiBlankRE = regexp.MustCompile(`\n{3,}`)
- sentenceEndRE = regexp.MustCompile(`([。!?…!?])\s*`)
- aiPhraseRE = regexp.MustCompile(`(?i)(很高興|感謝您的|希望這對您|作為一個|總而言之|綜上所述|不妨考慮|值得注意)`)
-)
-
-func isMentionReplyDraft(in ReplyDraftInput) bool {
- return strings.TrimSpace(in.ThreadPostText) != "" || strings.TrimSpace(in.ParentReplyText) != ""
-}
-
-func BuildReplyDraftPrompt(in ReplyDraftInput) (system string, user string) {
- if isMentionReplyDraft(in) {
- return buildMentionReplyDraftPrompt(in)
- }
- accountName := strings.TrimSpace(in.AccountName)
- if accountName == "" {
- accountName = "我"
- }
- postText := strings.TrimSpace(in.PostText)
- if postText == "" {
- postText = "(貼文內容未提供)"
- }
- replyText := strings.TrimSpace(in.ReplyText)
- replyToID := strings.TrimSpace(in.ReplyToID)
- personaBlock := strings.TrimSpace(in.PersonaBlock)
- if personaBlock == "" {
- personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)"
- }
-
- system = strings.TrimSpace(`
-你是 Threads 貼文作者本人,要在自己貼文底下回留言或接續串文。
-請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。
-
-硬規則:
-1. 人設語氣是最高優先:用字、節奏、距離感、價值觀必須符合【1. 人設】,不得變成通用網友或官方口吻。
-2. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。
-3. 先讀【2. 我的貼文】掌握語境;若有【3. 要回覆的留言】,要直接回應對方說的內容,不要自說自話。
-4. 禁止 AI 腔:不要「很高興/感謝您的/希望這對您有幫助/作為一個…/總而言之/綜上所述」這類句型。
-5. 禁止客服業配腔:不要硬推、不要條列式教學、不要一次講太多重點。
-6. 排版要像真人留言:2~4 個短段落,每段 1~2 句;段落之間必須換行(用實際換行,不要整段擠在一起)。
-7. 可口語、可省略主詞、可用 …、可 0~1 個 emoji;總長度約 40~180 字,最多不超過 280 字。
-8. 你是貼文作者本人在回,不要假裝是粉絲或路人。`)
-
- var b strings.Builder
- b.WriteString("【1. 人設】\n")
- b.WriteString(personaBlock)
- b.WriteString("\n\n帳號名稱:")
- b.WriteString(accountName)
- b.WriteString("\n\n【2. 我的貼文】\n")
- b.WriteString(postText)
- b.WriteString("\n\n【3. 要回覆的留言】\n")
- if replyToID != "" && replyText != "" {
- b.WriteString(replyText)
- b.WriteString("\n\n請依人設語氣,回覆這則留言。")
- } else {
- b.WriteString("(無特定留言,這是接續自己貼文的補充回覆/串文)")
- b.WriteString("\n\n請依人設語氣,寫一則自然接續這篇貼文的留言。")
- }
- user = b.String()
- return system, user
-}
-
-func buildMentionReplyDraftPrompt(in ReplyDraftInput) (system string, user string) {
- accountName := strings.TrimSpace(in.AccountName)
- if accountName == "" {
- accountName = "我"
- }
- threadText := strings.TrimSpace(in.ThreadPostText)
- if threadText == "" {
- threadText = strings.TrimSpace(in.PostText)
- }
- if threadText == "" {
- threadText = "(主串貼文內容未提供)"
- }
- parentText := strings.TrimSpace(in.ParentReplyText)
- mentionText := strings.TrimSpace(in.ReplyText)
- if mentionText == "" {
- mentionText = "(對方 @ 你的內容未提供)"
- }
- personaBlock := strings.TrimSpace(in.PersonaBlock)
- if personaBlock == "" {
- personaBlock = "(尚未設定人設,請用口語、像真人在 Threads 回留言)"
- }
-
- system = strings.TrimSpace(`
-你是 Threads 帳號本人,有人在你參與的串文裡 @ 你,你要回覆對方。
-請用台灣繁體中文,像真人用手機打字,完全不像 AI 或客服。
-
-硬規則:
-1. 人設語氣是最高優先:用字、節奏、距離感必須符合【1. 人設】。
-2. 先讀【2. 主串貼文】理解整串在講什麼;若有【3. 上層留言】,要知道留言脈絡。
-3. 你的回覆必須直接回應【4. 對方 @ 你的內容】,不要忽略對方 tag 你的那句話。
-4. 只輸出一則回覆正文:不要標題、不要 JSON、不要用引號包裹、不要解釋你怎麼寫。
-5. 禁止 AI 腔與客服腔;2~4 個短段落,段落間換行;約 40~180 字,最多 280 字。
-6. 你是被 @ 的當事人在回,語氣自然、有來有往。`)
-
- var b strings.Builder
- b.WriteString("【1. 人設】\n")
- b.WriteString(personaBlock)
- b.WriteString("\n\n帳號名稱:")
- b.WriteString(accountName)
- b.WriteString("\n\n【2. 主串貼文】\n")
- b.WriteString(threadText)
- if parentText != "" {
- b.WriteString("\n\n【3. 上層留言】\n")
- b.WriteString(parentText)
- }
- b.WriteString("\n\n【4. 對方 @ 你的內容】\n")
- b.WriteString(mentionText)
- b.WriteString("\n\n請依人設語氣,回覆對方 @ 你的這則內容。")
- return system, b.String()
-}
-
-func GenerateReplyDraft(ctx context.Context, ai domai.UseCase, req domai.GenerateRequest, in ReplyDraftInput) (string, error) {
- system, user := BuildReplyDraftPrompt(in)
- temp := 0.9
- req.System = system
- req.Messages = []domai.Message{{Role: "user", Content: user}}
- req.Temperature = &temp
- out, err := ai.GenerateText(ctx, req)
- if err != nil {
- return "", err
- }
- text := NormalizeHumanReply(out.Text)
- if text == "" {
- return "", fmt.Errorf("AI 未回傳回覆草稿")
- }
- return text, nil
-}
-
-// NormalizeHumanReply trims AI artifacts and nudges layout toward short mobile-style paragraphs.
-func NormalizeHumanReply(raw string) string {
- text := strings.TrimSpace(raw)
- if text == "" {
- return ""
- }
- if m := wrapQuoteRE.FindStringSubmatch(text); len(m) == 2 {
- text = strings.TrimSpace(m[1])
- }
- text = strings.ReplaceAll(text, `\n`, "\n")
- text = multiBlankRE.ReplaceAllString(text, "\n\n")
- text = strings.TrimSpace(text)
-
- if !strings.Contains(text, "\n") {
- if sentenceCount(text) >= 2 || utf8.RuneCountInString(text) > 72 {
- text = breakLongReply(text)
- }
- }
- if aiPhraseRE.MatchString(text) && !strings.Contains(text, "\n") {
- text = breakLongReply(text)
- }
- return strings.TrimSpace(text)
-}
-
-func sentenceCount(text string) int {
- n := 0
- for _, r := range text {
- switch r {
- case '。', '!', '?', '…', '!', '?':
- n++
- }
- }
- return n
-}
-
-func breakLongReply(text string) string {
- parts := sentenceEndRE.Split(text, -1)
- seps := sentenceEndRE.FindAllString(text, -1)
- if len(parts) <= 1 {
- return text
- }
- var chunks []string
- var current strings.Builder
- for i, part := range parts {
- part = strings.TrimSpace(part)
- if part == "" {
- continue
- }
- sep := ""
- if i < len(seps) {
- sep = strings.TrimSpace(seps[i])
- }
- segment := part + sep
- if current.Len() == 0 {
- current.WriteString(segment)
- continue
- }
- if utf8.RuneCountInString(current.String())+utf8.RuneCountInString(segment) > 42 {
- chunks = append(chunks, strings.TrimSpace(current.String()))
- current.Reset()
- current.WriteString(segment)
- continue
- }
- current.WriteString(segment)
- }
- if current.Len() > 0 {
- chunks = append(chunks, strings.TrimSpace(current.String()))
- }
- if len(chunks) <= 1 {
- return text
- }
- return strings.Join(chunks, "\n")
-}
diff --git a/old/backend/internal/library/ownpost/reply_draft_test.go b/old/backend/internal/library/ownpost/reply_draft_test.go
deleted file mode 100644
index 975536d..0000000
--- a/old/backend/internal/library/ownpost/reply_draft_test.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package ownpost
-
-import (
- "strings"
- "testing"
-)
-
-func TestBuildReplyDraftPromptIncludesPersonaPostAndReply(t *testing.T) {
- _, user := BuildReplyDraftPrompt(ReplyDraftInput{
- AccountName: "小安",
- PersonaBlock: "溫柔媽媽,說話慢、會先共情",
- PostText: "今天帶小孩好累",
- ReplyText: "我也是欸",
- ReplyToID: "123",
- })
- for _, want := range []string{"【1. 人設】", "溫柔媽媽", "【2. 我的貼文】", "今天帶小孩好累", "【3. 要回覆的留言】", "我也是欸"} {
- if !strings.Contains(user, want) {
- t.Fatalf("missing %q in user prompt:\n%s", want, user)
- }
- }
-}
-
-func TestBuildReplyDraftPromptThreadContinuation(t *testing.T) {
- _, user := BuildReplyDraftPrompt(ReplyDraftInput{
- PersonaBlock: "生活觀察者",
- PostText: "最近在想轉職",
- })
- if !strings.Contains(user, "接續自己貼文") {
- t.Fatalf("expected thread continuation hint, got:\n%s", user)
- }
- if strings.Contains(user, "【3. 要回覆的留言】\n(無特定留言") {
- // ok
- } else if !strings.Contains(user, "無特定留言") {
- t.Fatalf("expected empty reply section marker, got:\n%s", user)
- }
-}
-
-func TestBuildMentionReplyDraftPromptIncludesThreadAndMention(t *testing.T) {
- _, user := BuildReplyDraftPrompt(ReplyDraftInput{
- AccountName: "小安",
- PersonaBlock: "直率科技人",
- ThreadPostText: "今天來分享育兒心得",
- ParentReplyText: "我覺得放鬆最重要",
- ReplyText: "@小安 你覺得呢?",
- ReplyToID: "999",
- })
- for _, want := range []string{
- "【2. 主串貼文】", "今天來分享育兒心得",
- "【3. 上層留言】", "我覺得放鬆最重要",
- "【4. 對方 @ 你的內容】", "@小安 你覺得呢?",
- } {
- if !strings.Contains(user, want) {
- t.Fatalf("missing %q in mention user prompt:\n%s", want, user)
- }
- }
-}
-
-func TestNormalizeHumanReplyStripsQuotesAndBreaksLines(t *testing.T) {
- got := NormalizeHumanReply(`"第一段真的超累到不想講話。第二段後來洗個澡就還好一點。第三段你加油,我們一起撐過這週。"`)
- if strings.HasPrefix(got, `"`) {
- t.Fatalf("expected quotes stripped, got %q", got)
- }
- if !strings.Contains(got, "\n") {
- t.Fatalf("expected line breaks, got %q", got)
- }
-}
diff --git a/old/backend/internal/library/permmatch/match.go b/old/backend/internal/library/permmatch/match.go
deleted file mode 100644
index 2fe7075..0000000
--- a/old/backend/internal/library/permmatch/match.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package permmatch
-
-import (
- "strings"
-)
-
-// MethodAllowed reports whether method is covered by a pipe-separated methods string.
-func MethodAllowed(methods, method string) bool {
- method = strings.ToUpper(strings.TrimSpace(method))
- if method == "" {
- return false
- }
- for _, item := range strings.Split(methods, "|") {
- if strings.ToUpper(strings.TrimSpace(item)) == method {
- return true
- }
- }
- return false
-}
-
-// PathAllowed reports whether requestPath matches pattern (exact or trailing * wildcard).
-func PathAllowed(pattern, requestPath string) bool {
- pattern = strings.TrimSpace(pattern)
- requestPath = strings.TrimSpace(requestPath)
- if pattern == "" || requestPath == "" {
- return false
- }
- if pattern == requestPath {
- return true
- }
- if strings.HasSuffix(pattern, "*") {
- prefix := strings.TrimRight(strings.TrimSuffix(pattern, "*"), "/")
- if requestPath == prefix {
- return true
- }
- return strings.HasPrefix(requestPath, prefix+"/")
- }
- return false
-}
-
-// RequestAllowed checks member permission map against an HTTP request.
-func RequestAllowed(permissions map[string]string, method, requestPath string) bool {
- if len(permissions) == 0 {
- return false
- }
- for pattern, methods := range permissions {
- if PathAllowed(pattern, requestPath) && MethodAllowed(methods, method) {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/permmatch/match_test.go b/old/backend/internal/library/permmatch/match_test.go
deleted file mode 100644
index 3bfbf18..0000000
--- a/old/backend/internal/library/permmatch/match_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package permmatch
-
-import "testing"
-
-func TestMethodAllowed(t *testing.T) {
- if !MethodAllowed("GET|POST", "get") {
- t.Fatal("expected GET")
- }
- if MethodAllowed("GET|POST", "PATCH") {
- t.Fatal("expected no PATCH")
- }
-}
-
-func TestPathAllowed(t *testing.T) {
- if !PathAllowed("/api/v1/jobs/*", "/api/v1/jobs/abc/cancel") {
- t.Fatal("expected wildcard match")
- }
- if PathAllowed("/api/v1/jobs/*", "/api/v1/job/schedules") {
- t.Fatal("expected no match")
- }
- if !PathAllowed("/api/v1/members/me", "/api/v1/members/me") {
- t.Fatal("expected exact match")
- }
-}
-
-func TestRequestAllowed(t *testing.T) {
- perms := map[string]string{
- "/api/v1/members/me": "GET|PATCH",
- "/api/v1/jobs/*": "GET|POST",
- "/api/v1/threads-accounts/*": "GET|POST|PUT|PATCH|DELETE",
- }
- if !RequestAllowed(perms, "GET", "/api/v1/members/me") {
- t.Fatal("expected member me")
- }
- if RequestAllowed(perms, "DELETE", "/api/v1/members/me") {
- t.Fatal("expected deny delete")
- }
- if !RequestAllowed(perms, "POST", "/api/v1/jobs/x/cancel") {
- t.Fatal("expected job cancel")
- }
- if !RequestAllowed(perms, "DELETE", "/api/v1/threads-accounts/acc-1") {
- t.Fatal("expected threads account delete")
- }
- if !RequestAllowed(perms, "POST", "/api/v1/threads-accounts/acc-1/activate") {
- t.Fatal("expected threads account activate")
- }
-}
-
-func TestPathAllowedListRoot(t *testing.T) {
- for _, path := range []string{"/api/v1/personas", "/api/v1/jobs", "/api/v1/brands"} {
- pattern := path + "/*"
- if !PathAllowed(pattern, path) {
- t.Fatalf("expected list root match for %s", path)
- }
- }
- if PathAllowed("/api/v1/jobs/*", "/api/v1/job/schedules") {
- t.Fatal("expected schedules path to stay blocked")
- }
-}
diff --git a/old/backend/internal/library/personacopy/formula_draft.go b/old/backend/internal/library/personacopy/formula_draft.go
deleted file mode 100644
index e83795e..0000000
--- a/old/backend/internal/library/personacopy/formula_draft.go
+++ /dev/null
@@ -1,161 +0,0 @@
-package personacopy
-
-import (
- "context"
- "fmt"
- "strings"
-
- libformula "haixun-backend/internal/library/formula"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/style8d"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
- copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type FormulaDraftInput struct {
- TenantID string
- OwnerUID string
- PersonaID string
- AccountID string
- FormulaID string
- Topic string
- Brief string
- UseWebSearch bool
- DraftCount int
-}
-
-type FormulaDraftDeps struct {
- Persona personadomain.UseCase
- ContentFormula contentformuladomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI domai.UseCase
-}
-
-func RunFormulaDraft(ctx context.Context, deps FormulaDraftDeps, in FormulaDraftInput, progress ProgressFn) (int, error) {
- if progress == nil {
- progress = func(string, int) {}
- }
- topic := strings.TrimSpace(in.Topic)
- if topic == "" {
- return 0, fmt.Errorf("topic is required")
- }
- count := in.DraftCount
- if count <= 0 {
- count = 1
- }
- if count > 5 {
- count = 5
- }
-
- progress("讀取人設與寫法公式…", 10)
- persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
- if err != nil {
- return 0, err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return 0, fmt.Errorf("請先完成人設 8D 對標分析")
- }
- formula, err := deps.ContentFormula.Get(ctx, in.TenantID, in.OwnerUID, in.AccountID, in.FormulaID)
- if err != nil {
- return 0, err
- }
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
- if err != nil {
- return 0, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return 0, err
- }
- aiReq := domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{APIKey: credential.APIKey},
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- researchNotes := ""
- if in.UseWebSearch {
- progress("搜尋網路參考資料…", 20)
- researchNotes = searchFormulaNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief)
- }
-
- created := 0
- for i := 0; i < count; i++ {
- progress(fmt.Sprintf("呼叫 AI 產生草稿 %d/%d(%s / %s)…", i+1, count, credential.Provider, credential.Model), 35+(i*40/count))
- generated, genErr := libformula.GenerateDraft(ctx, deps.AI, aiReq, libformula.GenerateInput{
- Topic: topic,
- Brief: in.Brief,
- PersonaBlock: personaBlock,
- ResearchNotes: researchNotes,
- Formula: *formula,
- })
- if genErr != nil {
- return created, genErr
- }
- if _, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
- TenantID: in.TenantID,
- OwnerUID: in.OwnerUID,
- PersonaID: in.PersonaID,
- FormulaID: formula.ID,
- DraftType: copydraftentity.DraftTypeFormula,
- Text: generated.Text,
- TopicTag: generated.TopicTag,
- Angle: generated.Angle,
- Hook: generated.Hook,
- Rationale: generated.Rationale,
- ReferenceNotes: generated.StructureNotes,
- Sources: []string{formula.Label},
- }); saveErr != nil {
- return created, saveErr
- }
- created++
- }
- progress(fmt.Sprintf("已產生 %d 篇草稿", created), 100)
- return created, nil
-}
-
-func searchFormulaNotes(ctx context.Context, deps FormulaDraftDeps, tenantID, uid, topic, brief string) string {
- research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid)
- if err != nil || !placement.WebSearchAvailable(research) {
- return ""
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research)
- if err != nil {
- return ""
- }
- webClient := websearch.New(memberCtx.WebSearchConfig())
- if !webClient.Enabled() {
- return ""
- }
- resp, err := webClient.Search(ctx, websearch.SearchOptions{
- Query: topic + " " + strings.TrimSpace(brief),
- Limit: 5,
- Mode: websearch.ModeKnowledgeExpand,
- })
- if err != nil || len(resp.Results) == 0 {
- return ""
- }
- var b strings.Builder
- for _, snip := range resp.Results {
- if snip.Title != "" {
- b.WriteString("- ")
- b.WriteString(snip.Title)
- b.WriteString("\n")
- }
- if snip.Snippet != "" {
- b.WriteString(snip.Snippet)
- b.WriteString("\n")
- }
- }
- return strings.TrimSpace(b.String())
-}
diff --git a/old/backend/internal/library/personacopy/rewrite_draft.go b/old/backend/internal/library/personacopy/rewrite_draft.go
deleted file mode 100644
index 56bf638..0000000
--- a/old/backend/internal/library/personacopy/rewrite_draft.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package personacopy
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- libformula "haixun-backend/internal/library/formula"
- "haixun-backend/internal/library/style8d"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- cfentity "haixun-backend/internal/model/content_formula/domain/entity"
- contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type RewriteDraftInput struct {
- TenantID string
- OwnerUID string
- PersonaID string
- AccountID string
- ReferenceText string
- Topic string
- Brief string
- SaveLabel string
- UseWebSearch bool
- DraftCount int
-}
-
-type RewriteDraftDeps struct {
- Persona personadomain.UseCase
- ContentFormula contentformuladomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI domai.UseCase
-}
-
-func RunRewriteDraft(ctx context.Context, deps RewriteDraftDeps, in RewriteDraftInput, progress ProgressFn) (int, error) {
- if progress == nil {
- progress = func(string, int) {}
- }
- referenceText := strings.TrimSpace(in.ReferenceText)
- topic := strings.TrimSpace(in.Topic)
- if referenceText == "" {
- return 0, fmt.Errorf("reference_text is required")
- }
- if topic == "" {
- return 0, fmt.Errorf("topic is required")
- }
-
- progress("讀取人設…", 5)
- persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
- if err != nil {
- return 0, err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return 0, fmt.Errorf("請先完成人設 8D 對標分析")
- }
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
- if err != nil {
- return 0, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return 0, err
- }
- aiReq := domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{APIKey: credential.APIKey},
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- progress(fmt.Sprintf("分析參考貼文寫法(%s / %s)…", credential.Provider, credential.Model), 15)
- analysis, err := libformula.AnalyzePasted(ctx, deps.AI, aiReq, libformula.AnalyzePastedInput{
- PostText: referenceText,
- PersonaBlock: personaBlock,
- })
- if err != nil {
- return 0, fmt.Errorf("分析參考貼文失敗:%w", err)
- }
- if analysis == nil {
- return 0, fmt.Errorf("分析未產出有效寫法公式")
- }
-
- label := strings.TrimSpace(in.SaveLabel)
- if label == "" {
- label = fmt.Sprintf("貼文改寫 %s", time.Now().UTC().Format("2006-01-02"))
- }
- progress("存入寫法公式…", 30)
- saved, err := deps.ContentFormula.Create(ctx, contentformuladomain.CreateRequest{
- TenantID: in.TenantID,
- OwnerUID: in.OwnerUID,
- AccountID: in.AccountID,
- Label: label,
- SourceType: cfentity.SourcePaste,
- SourceRef: "paste",
- SourcePostText: referenceText,
- Summary: analysis.Summary,
- Wins: analysis.Wins,
- Improvements: analysis.Improvements,
- Formula: analysis.Formula,
- PostTemplate: analysis.PostTemplate,
- HookPattern: analysis.HookPattern,
- Structure: analysis.Structure,
- ReplicationTips: analysis.ReplicationTips,
- Avoid: analysis.Avoid,
- })
- if err != nil {
- return 0, err
- }
-
- return RunFormulaDraft(ctx, FormulaDraftDeps{
- Persona: deps.Persona,
- ContentFormula: deps.ContentFormula,
- CopyDraft: deps.CopyDraft,
- ThreadsAccount: deps.ThreadsAccount,
- Placement: deps.Placement,
- AI: deps.AI,
- }, FormulaDraftInput{
- TenantID: in.TenantID,
- OwnerUID: in.OwnerUID,
- PersonaID: in.PersonaID,
- AccountID: in.AccountID,
- FormulaID: saved.ID,
- Topic: topic,
- Brief: in.Brief,
- UseWebSearch: in.UseWebSearch,
- DraftCount: in.DraftCount,
- }, func(summary string, percentage int) {
- adjusted := 35 + (percentage * 65 / 100)
- progress(summary, adjusted)
- })
-}
diff --git a/old/backend/internal/library/personacopy/topic_matrix.go b/old/backend/internal/library/personacopy/topic_matrix.go
deleted file mode 100644
index 9fc2453..0000000
--- a/old/backend/internal/library/personacopy/topic_matrix.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package personacopy
-
-import (
- "context"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/copyvoice"
- libmatrix "haixun-backend/internal/library/matrix"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/style8d"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
- copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type TopicMatrixInput struct {
- TenantID string
- OwnerUID string
- PersonaID string
- ContentPlanID string
- Topic string
- Brief string
- UseWebSearch bool
- DraftCount int
-}
-
-type TopicMatrixDeps struct {
- Persona personadomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ContentOps contentopsdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI domai.UseCase
-}
-
-type ProgressFn func(summary string, percentage int)
-
-func RunTopicMatrix(ctx context.Context, deps TopicMatrixDeps, in TopicMatrixInput, progress ProgressFn) ([]copydraftdomain.CopyDraftSummary, error) {
- if progress == nil {
- progress = func(string, int) {}
- }
- topic := strings.TrimSpace(in.Topic)
- if topic == "" {
- return nil, fmt.Errorf("topic is required")
- }
- count := in.DraftCount
- if count <= 0 {
- count = 3
- }
- if count < 1 {
- count = 1
- }
- if count > 5 {
- count = 5
- }
-
- progress("讀取人設語氣…", 10)
- persona, err := deps.Persona.Get(ctx, in.TenantID, in.OwnerUID, in.PersonaID)
- if err != nil {
- return nil, err
- }
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, in.TenantID, in.OwnerUID)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- researchNotes := ""
- if in.UseWebSearch {
- progress("搜尋網路參考資料…", 25)
- researchNotes = searchTopicNotes(ctx, deps, in.TenantID, in.OwnerUID, topic, in.Brief)
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
- feedbackNotes := recentFeedbackNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID)
- knowledgeNotes := recentKnowledgeNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID, topic)
- formulaNotes := formulaPoolNotes(ctx, deps, in.TenantID, in.OwnerUID, in.PersonaID)
-
- progress(fmt.Sprintf("呼叫 AI 產生 %d 篇草稿(%s / %s)…", count, credential.Provider, credential.Model), 45)
- parsed, err := libmatrix.GenerateCopyOutput(ctx, deps.AI, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{APIKey: credential.APIKey},
- System: TopicMatrixSystemPrompt(),
- Messages: []domai.Message{{Role: "user", Content: TopicMatrixUserPrompt(topic, in.Brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes, count)}},
- }, count)
- if err != nil {
- return nil, err
- }
-
- progress("寫入草稿…", 85)
- created := make([]copydraftdomain.CopyDraftSummary, 0, len(parsed.Rows))
- for _, row := range parsed.Rows {
- saved, saveErr := deps.CopyDraft.Create(ctx, copydraftdomain.CreateRequest{
- TenantID: in.TenantID,
- OwnerUID: in.OwnerUID,
- PersonaID: in.PersonaID,
- ContentPlanID: in.ContentPlanID,
- DraftType: copydraftentity.DraftTypeMatrix,
- SortOrder: row.SortOrder,
- Text: row.Text,
- TopicTag: row.SearchTag,
- Angle: row.Angle,
- Hook: row.Hook,
- Rationale: row.Rationale,
- ReferenceNotes: row.ReferenceNotes,
- Sources: row.SourcePermalinks,
- AiScore: row.AiScore,
- FormulaScore: row.FormulaScore,
- BrandFitScore: row.BrandFitScore,
- RiskScore: row.RiskScore,
- SimilarityScore: row.SimilarityScore,
- EngagementPotential: row.EngagementPotential,
- FreshnessScore: row.FreshnessScore,
- ReviewSuggestion: row.ReviewSuggestion,
- })
- if saveErr != nil {
- return created, saveErr
- }
- created = append(created, *saved)
- }
- progress(fmt.Sprintf("已產生 %d 篇草稿", len(created)), 100)
- return created, nil
-}
-
-func recentFeedbackNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string {
- if deps.ContentOps == nil {
- return ""
- }
- items, err := deps.ContentOps.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, 8)
- if err != nil || len(items) == 0 {
- return ""
- }
- var b strings.Builder
- for _, item := range items {
- decision := strings.TrimSpace(item.Decision)
- note := strings.TrimSpace(item.Note)
- if decision == "" && note == "" {
- continue
- }
- b.WriteString("- ")
- if decision != "" {
- b.WriteString(decision)
- }
- if note != "" {
- b.WriteString(":")
- b.WriteString(note)
- }
- b.WriteString("\n")
- }
- return strings.TrimSpace(b.String())
-}
-
-func recentKnowledgeNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID, topic string) string {
- if deps.ContentOps == nil {
- return ""
- }
- items, err := deps.ContentOps.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, 12)
- if err != nil {
- return ""
- }
- var b strings.Builder
- for _, item := range items {
- content := strings.TrimSpace(item.Content)
- if content == "" {
- continue
- }
- b.WriteString("- ")
- b.WriteString(shorten(content, 220))
- if item.RiskLevel != "" {
- b.WriteString("(風險:")
- b.WriteString(item.RiskLevel)
- b.WriteString(")")
- }
- b.WriteString("\n")
- }
- return strings.TrimSpace(b.String())
-}
-
-func formulaPoolNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, ownerUID, personaID string) string {
- if deps.ContentOps == nil {
- return ""
- }
- items, err := deps.ContentOps.ListFormulaPools(ctx, tenantID, ownerUID, personaID, 12)
- if err != nil {
- return ""
- }
- var b strings.Builder
- for _, item := range items {
- b.WriteString("- ")
- b.WriteString(item.Type)
- b.WriteString(" / ")
- b.WriteString(item.Name)
- if item.Pattern != "" {
- b.WriteString(":")
- b.WriteString(item.Pattern)
- }
- if len(item.Avoid) > 0 {
- b.WriteString(";避免:")
- b.WriteString(strings.Join(item.Avoid, "、"))
- }
- b.WriteString("\n")
- }
- return strings.TrimSpace(b.String())
-}
-
-func shorten(raw string, max int) string {
- r := []rune(strings.TrimSpace(raw))
- if len(r) <= max {
- return string(r)
- }
- return string(r[:max]) + "..."
-}
-
-func searchTopicNotes(ctx context.Context, deps TopicMatrixDeps, tenantID, uid, topic, brief string) string {
- research, err := deps.Placement.ResearchSettings(ctx, tenantID, uid)
- if err != nil || !placement.WebSearchAvailable(research) {
- return ""
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, uid, research)
- if err != nil {
- return ""
- }
- webClient := websearch.New(memberCtx.WebSearchConfig())
- if !webClient.Enabled() {
- return ""
- }
- resp, err := webClient.Search(ctx, websearch.SearchOptions{
- Query: strings.TrimSpace(topic + " " + brief),
- Limit: 5,
- Mode: websearch.ModeKnowledgeExpand,
- })
- if err != nil || len(resp.Results) == 0 {
- return ""
- }
- var b strings.Builder
- for _, item := range resp.Results {
- if item.Title != "" {
- b.WriteString("- ")
- b.WriteString(item.Title)
- b.WriteString("\n")
- }
- if item.Snippet != "" {
- b.WriteString(item.Snippet)
- b.WriteString("\n")
- }
- if item.URL != "" {
- b.WriteString(item.URL)
- b.WriteString("\n")
- }
- }
- return strings.TrimSpace(b.String())
-}
-
-func TopicMatrixSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads / Instagram 貼文編輯。請依人設語言指紋產出自然、可直接發布的繁體中文貼文矩陣。
-
-` + copyvoice.SystemRules() + `
-
-規則:
-- 不要每篇都用同一套 hook / 三段式 / 結尾問句。
-- 不要企業八股、不要像教科書、不要一直喊口號。
-- 人設定位決定誰在說,語言指紋決定怎麼說;必須遵守段落節奏、標點換行、知識轉譯與 CTA 習慣。
-- 不要把人設介紹裡的物件/標籤硬寫進正文情境。例:人設寫 Y2K、咖啡、穿搭,只代表語感與審美,不代表每篇都要出現老歌、咖啡、穿搭、MV。
-- 正文內容必須由【話題】與【補充】決定;人設不得蓋過主題。
-- 每篇要有不同角度;長度與格式依人設習慣和主題型態決定,不要強制短文。
-- 每次至少產出 3 種版本思路:穩定帳號風格、更有共鳴、更容易互動;若 count 超過 3,再補更短或更反差版本。
-- text 只能放貼文正文,不要放標題、分析、公式、模板或說明。
-- text 排版:像真人手機發文;可使用自然標點、空行、列點與少量 emoji。不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣。
-- 只回傳 JSON 物件:{"rows":[...]}。
-- 每筆 row 必須包含 sort_order, search_tag, angle, hook, text, reference_notes, source_permalinks, rationale, ai_score, formula_score, brand_fit_score, risk_score, similarity_score, engagement_potential, freshness_score, review_suggestion。
-- 分數欄位都是 0-100 整數:ai_score / formula_score / risk_score / similarity_score 越低越好;brand_fit_score / engagement_potential / freshness_score 越高越好。
-- review_suggestion 只能是「可通過」「需人工確認」「建議重寫」之一。
-- rationale 必須用繁中短列點包含:版本定位、AI感分數(0-100,越高越AI)、公式感分數(0-100)、人設符合度(0-100)、風險分數(0-100)、互動潛力(0-100)、審核建議(可通過/需人工確認/建議重寫)與原因。`)
-}
-
-func TopicMatrixUserPrompt(topic, brief, personaBlock, researchNotes, knowledgeNotes, formulaNotes, feedbackNotes string, count int) string {
- var b strings.Builder
- b.WriteString("請產生 ")
- b.WriteString(fmt.Sprintf("%d", count))
- b.WriteString(" 篇 Threads 草稿。\n\n【話題】\n")
- b.WriteString(strings.TrimSpace(topic))
- if brief = strings.TrimSpace(brief); brief != "" {
- b.WriteString("\n\n【補充】\n")
- b.WriteString(brief)
- }
- if personaBlock = strings.TrimSpace(personaBlock); personaBlock != "" {
- b.WriteString("\n\n【人設定位、語言指紋與禁忌(最高優先)】\n")
- b.WriteString(personaBlock)
- b.WriteString("\n\n")
- b.WriteString(copyvoice.PersonaCalibrationNote())
- }
- if researchNotes = strings.TrimSpace(researchNotes); researchNotes != "" {
- b.WriteString("\n\n【網路資料參考】\n")
- b.WriteString(researchNotes)
- }
- if knowledgeNotes = strings.TrimSpace(knowledgeNotes); knowledgeNotes != "" {
- b.WriteString("\n\n【Knowledge Memory:帳號知識來源,優先採用但不要硬塞】\n")
- b.WriteString(knowledgeNotes)
- }
- if formulaNotes = strings.TrimSpace(formulaNotes); formulaNotes != "" {
- b.WriteString("\n\n【Formula Pool:可選公式池,請依 Content Plan 選擇,不要每篇都套同一套】\n")
- b.WriteString(formulaNotes)
- }
- if feedbackNotes = strings.TrimSpace(feedbackNotes); feedbackNotes != "" {
- b.WriteString("\n\n【最近人工審核回饋(Feedback Memory,必須吸收)】\n")
- b.WriteString(feedbackNotes)
- }
- b.WriteString("\n\n【品質閘門】\n請先自我檢查 AI 感、公式感、事實風險、人設一致性、與過去常見句型重複。高風險內容不得建議直接通過。")
- b.WriteString("\n\n請讓每篇的 angle 明確不同,search_tag 是短主題標籤不含 #。source_permalinks 可放參考 URL,沒有就空陣列。")
- return b.String()
-}
diff --git a/old/backend/internal/library/placement/ai_generate.go b/old/backend/internal/library/placement/ai_generate.go
deleted file mode 100644
index 374eee8..0000000
--- a/old/backend/internal/library/placement/ai_generate.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package placement
-
-import domai "haixun-backend/internal/model/ai/domain/usecase"
-
-// ResearchGenerateRequest wraps a text generation call with defaults tuned for
-// placement research map / knowledge graph (full JSON, not terse summaries).
-func ResearchGenerateRequest(base domai.GenerateRequest) domai.GenerateRequest {
- temp := 0.45
- tokens := 12288
- base.Temperature = &temp
- base.MaxTokens = &tokens
- return base
-}
diff --git a/old/backend/internal/library/placement/capabilities.go b/old/backend/internal/library/placement/capabilities.go
deleted file mode 100644
index 424bdf8..0000000
--- a/old/backend/internal/library/placement/capabilities.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package placement
-
-import (
- "strings"
-)
-
-// MemberCapabilities summarizes which external integrations are ready for the
-// current member + active Threads operating account.
-type MemberCapabilities struct {
- DiscoverReady bool
- AiReady bool
- PublishReady bool
- DiscoverHint string
- AiHint string
- PublishHint string
- ActiveThreadsAccount string
-}
-
-func BuildMemberCapabilities(member MemberContext, research ResearchSettings, aiReady, publishReady bool) MemberCapabilities {
- out := MemberCapabilities{
- DiscoverReady: member.HasDiscoverPath(),
- AiReady: aiReady,
- PublishReady: publishReady,
- ActiveThreadsAccount: strings.TrimSpace(member.ActiveAccountID),
- }
- if !out.DiscoverReady {
- out.DiscoverHint = discoverCapabilityHint(member, research)
- }
- if !out.AiReady {
- out.AiHint = "請先在設定頁設定 AI API key"
- }
- if !out.PublishReady {
- out.PublishHint = publishCapabilityHint(member)
- }
- return out
-}
-
-func discoverCapabilityHint(member MemberContext, research ResearchSettings) string {
- if strings.TrimSpace(member.ActiveAccountID) == "" {
- return "請先建立並選定經營帳號"
- }
- return discoverMissingPathError(member).Error()
-}
-
-func publishCapabilityHint(member MemberContext) string {
- if strings.TrimSpace(member.ActiveAccountID) == "" {
- return "請先建立並選定經營帳號"
- }
- return "請先完成 Threads API 連線後再發布貼文"
-}
diff --git a/old/backend/internal/library/placement/context.go b/old/backend/internal/library/placement/context.go
deleted file mode 100644
index 3b128ae..0000000
--- a/old/backend/internal/library/placement/context.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package placement
-
-import (
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/websearch"
-)
-
-// ConnectionPrefsInput mirrors persisted account connection prefs without importing threads_account.
-type ConnectionPrefsInput struct {
- DevMode bool
- SearchSourceMode string
-}
-
-// MemberContext is resolved per login member (email account) + active Threads operating account.
-type MemberContext struct {
- TenantID string
- OwnerUID string
- ActiveAccountID string
- DevMode bool
- SearchSourceMode SearchSourceMode
- AllowsThreadsAPI bool
- AllowsBrave bool
- AllowsCrawler bool
- WebSearchProvider string
- BraveAPIKey string
- ExaAPIKey string
- BraveCountry string
- BraveSearchLang string
- ExaUserLocation string
- ApiConnected bool
- BrowserConnected bool
- ThreadsAPIAccessToken string
- ScrapeReplies bool
- RepliesPerPost int
-}
-
-type ResearchSettings struct {
- WebSearchProvider string
- BraveAPIKey string
- ExaAPIKey string
- BraveCountry string
- BraveSearchLang string
- ExaUserLocation string
- ExpandStrategy string
-}
-
-func BuildMemberContext(
- tenantID, ownerUID, activeAccountID string,
- prefs ConnectionPrefsInput,
- apiConnected, browserConnected bool,
- research ResearchSettings,
- scrapeReplies bool,
- repliesPerPost int,
-) MemberContext {
- mode := ParseSearchSourceMode(prefs.SearchSourceMode)
- if prefs.DevMode && strings.TrimSpace(prefs.SearchSourceMode) == "" {
- mode = SearchSourceCrawler
- }
- allowsCrawler := ModeAllowsCrawler(mode)
- allowsThreads := ModeAllowsThreadsAPI(mode)
- allowsBrave := ModeAllowsBrave(mode)
-
- country := strings.TrimSpace(research.BraveCountry)
- if country == "" {
- country = "tw"
- }
- lang := strings.TrimSpace(research.BraveSearchLang)
- if lang == "" {
- lang = "zh-hant"
- }
- userLocation := strings.TrimSpace(research.ExaUserLocation)
- if userLocation == "" {
- userLocation = "TW"
- }
-
- if repliesPerPost <= 0 {
- repliesPerPost = 10
- }
-
- return MemberContext{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- ActiveAccountID: activeAccountID,
- DevMode: prefs.DevMode,
- SearchSourceMode: mode,
- AllowsThreadsAPI: allowsThreads,
- AllowsBrave: allowsBrave,
- AllowsCrawler: allowsCrawler,
- WebSearchProvider: string(websearch.ParseProvider(research.WebSearchProvider)),
- BraveAPIKey: strings.TrimSpace(research.BraveAPIKey),
- ExaAPIKey: strings.TrimSpace(research.ExaAPIKey),
- BraveCountry: country,
- BraveSearchLang: lang,
- ExaUserLocation: userLocation,
- ApiConnected: apiConnected,
- BrowserConnected: browserConnected,
- ScrapeReplies: scrapeReplies,
- RepliesPerPost: repliesPerPost,
- }
-}
-
-// MemberContextForAPIOnly forces Threads / web-search discover and disables Playwright routing.
-// Used for all flows except explicit test-patrol jobs.
-func MemberContextForAPIOnly(base MemberContext) MemberContext {
- base.DevMode = false
- base.SearchSourceMode = WithoutCrawler(base.SearchSourceMode)
- base.AllowsCrawler = false
- base.AllowsThreadsAPI = ModeAllowsThreadsAPI(base.SearchSourceMode)
- base.AllowsBrave = ModeAllowsBrave(base.SearchSourceMode)
- return base
-}
-
-// MemberContextForCrawlerOnly forces Playwright-only discover for test patrol jobs.
-func MemberContextForCrawlerOnly(base MemberContext) MemberContext {
- base.DevMode = true
- base.SearchSourceMode = SearchSourceCrawler
- base.AllowsCrawler = true
- base.AllowsBrave = false
- base.AllowsThreadsAPI = false
- return base
-}
-
-func (c MemberContext) PayloadFields() map[string]any {
- return map[string]any{
- "tenant_id": c.TenantID,
- "owner_uid": c.OwnerUID,
- "threads_account_id": c.ActiveAccountID,
- "dev_mode": c.DevMode,
- "search_source_mode": string(c.SearchSourceMode),
- "allows_threads_api": c.AllowsThreadsAPI,
- "allows_brave": c.AllowsBrave,
- "allows_crawler": c.AllowsCrawler,
- "web_search_provider": c.WebSearchProvider,
- "brave_country": c.BraveCountry,
- "brave_search_lang": c.BraveSearchLang,
- "exa_user_location": c.ExaUserLocation,
- "api_connected": c.ApiConnected,
- "browser_connected": c.BrowserConnected,
- "scrape_replies": c.ScrapeReplies,
- "replies_per_post": c.RepliesPerPost,
- }
-}
-
-func (c MemberContext) WebSearchProviderEnum() websearch.Provider {
- return websearch.ParseProvider(c.WebSearchProvider)
-}
-
-func (c MemberContext) WebSearchAPIKey() string {
- if c.WebSearchProviderEnum() == websearch.ProviderExa {
- return strings.TrimSpace(c.ExaAPIKey)
- }
- return strings.TrimSpace(c.BraveAPIKey)
-}
-
-func (c MemberContext) WebSearchConfig() websearch.Config {
- return websearch.ConfigFromMember(
- c.BraveAPIKey,
- c.ExaAPIKey,
- c.WebSearchProvider,
- c.BraveCountry,
- c.BraveSearchLang,
- c.ExaUserLocation,
- )
-}
-
-func (c MemberContext) WebSearchProviderLabel() string {
- return websearch.ProviderLabel(c.WebSearchProviderEnum())
-}
-
-func (c MemberContext) WebSearchDiscoverChannel() DiscoverChannel {
- if c.WebSearchProviderEnum() == websearch.ProviderExa {
- return DiscoverExa
- }
- return DiscoverBrave
-}
-
-func MissingWebSearchKey(research ResearchSettings) bool {
- return strings.TrimSpace(websearch.ActiveAPIKey(websearch.ConfigFromMember(
- research.BraveAPIKey,
- research.ExaAPIKey,
- research.WebSearchProvider,
- research.BraveCountry,
- research.BraveSearchLang,
- research.ExaUserLocation,
- ))) == ""
-}
-
-func WebSearchKeyRequiredMessage(research ResearchSettings) string {
- provider := websearch.ParseProvider(research.WebSearchProvider)
- return fmt.Sprintf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", websearch.ProviderLabel(provider))
-}
diff --git a/old/backend/internal/library/placement/context_api_only_test.go b/old/backend/internal/library/placement/context_api_only_test.go
deleted file mode 100644
index ad37a6b..0000000
--- a/old/backend/internal/library/placement/context_api_only_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package placement
-
-import "testing"
-
-func TestMemberContextForAPIOnlyDisablesCrawler(t *testing.T) {
- base := BuildMemberContext(
- "t", "u", "acc",
- ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)},
- true, true, ResearchSettings{}, false, 10,
- )
- apiOnly := MemberContextForAPIOnly(base)
- if apiOnly.AllowsCrawler {
- t.Fatal("API-only context must not allow crawler")
- }
- if apiOnly.DevMode {
- t.Fatal("API-only context must not be dev mode")
- }
- if !apiOnly.AllowsThreadsAPI {
- t.Fatal("API-only context should keep Threads API")
- }
-}
-
-func TestMemberContextForCrawlerOnlyStillWorks(t *testing.T) {
- base := MemberContextForAPIOnly(BuildMemberContext(
- "t", "u", "acc",
- ConnectionPrefsInput{SearchSourceMode: string(SearchSourceMixed)},
- true, true, ResearchSettings{}, false, 10,
- ))
- crawler := MemberContextForCrawlerOnly(base)
- if !crawler.AllowsCrawler || crawler.SearchSourceMode != SearchSourceCrawler {
- t.Fatal("test patrol should force crawler-only routing")
- }
-}
diff --git a/old/backend/internal/library/placement/context_prompt.go b/old/backend/internal/library/placement/context_prompt.go
deleted file mode 100644
index ddb1a9b..0000000
--- a/old/backend/internal/library/placement/context_prompt.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package placement
-
-import (
- "strings"
-
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
-)
-
-// PlacementTopicContext bundles brand, product, and user-entered topic fields for prompts.
-type PlacementTopicContext struct {
- BrandDisplayName string
- TopicName string
- SeedQuery string
- Brief string
- Goals string
- TargetAudience string
- ProductContext string
- ProductBrief string
- ProductLabel string
-}
-
-func PlacementTopicContextFromTopic(topic *topicdomain.TopicSummary, brand *branddomain.BrandSummary, productBrief string) PlacementTopicContext {
- if topic == nil {
- return PlacementTopicContextFromBrand(brand, productBrief)
- }
- merged := brandSummaryForTopic(topic, brand)
- return PlacementTopicContextFromBrand(merged, productBrief)
-}
-
-func brandSummaryForTopic(topic *topicdomain.TopicSummary, brand *branddomain.BrandSummary) *branddomain.BrandSummary {
- if brand == nil {
- return nil
- }
- out := *brand
- out.TopicName = strings.TrimSpace(topic.TopicName)
- out.SeedQuery = strings.TrimSpace(topic.SeedQuery)
- out.Brief = strings.TrimSpace(topic.Brief)
- out.ProductID = strings.TrimSpace(topic.ProductID)
- out.ResearchMap = topic.ResearchMap
- return &out
-}
-
-func PlacementTopicContextFromBrand(brand *branddomain.BrandSummary, productBrief string) PlacementTopicContext {
- if brand == nil {
- return PlacementTopicContext{}
- }
- ctx := PlacementTopicContext{
- BrandDisplayName: strings.TrimSpace(brand.DisplayName),
- TopicName: strings.TrimSpace(brand.TopicName),
- SeedQuery: strings.TrimSpace(brand.SeedQuery),
- Brief: strings.TrimSpace(brand.Brief),
- Goals: strings.TrimSpace(brand.Goals),
- TargetAudience: strings.TrimSpace(brand.TargetAudience),
- ProductContext: strings.TrimSpace(brand.ProductContext),
- ProductBrief: strings.TrimSpace(productBrief),
- ProductLabel: productLabelFromBrand(brand),
- }
- if ctx.ProductBrief == "" {
- ctx.ProductBrief = strings.TrimSpace(brand.ProductBrief)
- }
- return ctx
-}
-
-func productLabelFromBrand(brand *branddomain.BrandSummary) string {
- if brand == nil {
- return ""
- }
- productID := strings.TrimSpace(brand.ProductID)
- if productID == "" {
- return ""
- }
- for _, item := range brand.Products {
- if item.ID == productID {
- return strings.TrimSpace(item.Label)
- }
- }
- return ""
-}
-
-func (c PlacementTopicContext) ToResearchMapInput() ResearchMapInput {
- return ResearchMapInput{
- BrandDisplayName: c.BrandDisplayName,
- TopicName: c.TopicName,
- SeedQuery: c.SeedQuery,
- Brief: c.Brief,
- Goals: c.Goals,
- TargetAudience: c.TargetAudience,
- ProductContext: c.ProductContext,
- ProductBrief: c.ProductBrief,
- ProductLabel: c.ProductLabel,
- }
-}
-
-func (c PlacementTopicContext) WritePromptBlock(b *strings.Builder) {
- writePromptSection(b, "品牌", c.BrandDisplayName)
- writePromptSection(b, "置入產品", c.ProductDisplayName())
- writePromptSection(b, "主題名稱", c.TopicName)
- writePromptSection(b, "種子關鍵字", c.SeedQuery)
- writePromptSection(b, "主題目標", c.Brief)
- writePromptSection(b, "置入目標/備註", c.Goals)
- writePromptSection(b, "已知受眾描述", c.TargetAudience)
- productDetail := FormatProductContextForPrompt(c.ProductContext)
- if productDetail == "" && c.ProductBrief != "" {
- productDetail = c.ProductBrief
- }
- if productDetail != "" {
- b.WriteString("【產品詳情】\n")
- b.WriteString(productDetail)
- b.WriteString("\n")
- }
-}
-
-func (c PlacementTopicContext) ProductDisplayName() string {
- fields := ParseProductContext(c.ProductContext)
- if fields.Product != "" {
- return fields.Product
- }
- return c.ProductLabel
-}
-
-func writePromptSection(b *strings.Builder, label, value string) {
- value = strings.TrimSpace(value)
- if value == "" {
- return
- }
- b.WriteString("【")
- b.WriteString(label)
- b.WriteString("】")
- b.WriteString(value)
- b.WriteString("\n")
-}
-
-// PromptLines returns optional labeled lines for knowledge graph template vars.
-func (c PlacementTopicContext) PromptLines() map[string]string {
- productName := c.ProductDisplayName()
- return map[string]string{
- "brand_line": optionalPromptLine("品牌", c.BrandDisplayName),
- "topic_line": optionalPromptLine("主題名稱", c.TopicName),
- "product_line": optionalPromptLine("置入產品", productName),
- "goals_line": optionalPromptLine("置入目標", c.Goals),
- }
-}
-
-func optionalPromptLine(label, value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
- return label + ":" + value + "\n"
-}
diff --git a/old/backend/internal/library/placement/context_prompt_test.go b/old/backend/internal/library/placement/context_prompt_test.go
deleted file mode 100644
index 9f7653c..0000000
--- a/old/backend/internal/library/placement/context_prompt_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package placement
-
-import (
- "strings"
- "testing"
-
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
-)
-
-func TestPlacementTopicContextWritePromptBlock(t *testing.T) {
- ctx := PlacementTopicContextFromBrand(&branddomain.BrandSummary{
- DisplayName: "ecostore",
- TopicName: "癌症病友沐浴敏感",
- SeedQuery: "化療 無香 沐浴乳",
- Brief: "找近期求助換沐浴乳的貼文",
- ProductID: "p1",
- Products: []branddomain.ProductSummary{
- {ID: "p1", Label: "抗敏無香沐浴露", ProductContext: `{"brand":"ecostore","product":"抗敏無香沐浴露","features":"完全無香、抗敏、第三方認證"}`},
- },
- ProductContext: `{"brand":"ecostore","product":"抗敏無香沐浴露","features":"完全無香、抗敏、第三方認證"}`,
- }, "")
-
- var b strings.Builder
- ctx.WritePromptBlock(&b)
- out := b.String()
- for _, want := range []string{
- "【品牌】ecostore",
- "【置入產品】抗敏無香沐浴露",
- "【主題名稱】癌症病友沐浴敏感",
- "【種子關鍵字】化療 無香 沐浴乳",
- "【主題目標】找近期求助換沐浴乳的貼文",
- "【產品詳情】",
- "完全無香、抗敏、第三方認證",
- } {
- if !strings.Contains(out, want) {
- t.Fatalf("prompt block missing %q:\n%s", want, out)
- }
- }
-}
-
-func TestBuildResearchMapAnalysisPrompts(t *testing.T) {
- in := PlacementTopicContext{
- BrandDisplayName: "ecostore",
- TopicName: "癌症病友沐浴敏感",
- }.ToResearchMapInput()
- sys := BuildResearchMapAnalysisSystemPrompt()
- if !strings.Contains(sys, "不要輸出 JSON") {
- t.Fatal("analysis system prompt should forbid JSON")
- }
- user := BuildResearchMapAnalysisUserPrompt(in)
- if !strings.Contains(user, "【品牌】ecostore") {
- t.Fatalf("analysis user prompt missing brand: %s", user)
- }
- final := BuildResearchMapFinalizeUserPrompt(in, "分析段落")
- if !strings.Contains(final, "【前置分析】") || !strings.Contains(final, "分析段落") {
- t.Fatalf("finalize prompt missing analysis: %s", final)
- }
-}
diff --git a/old/backend/internal/library/placement/crawler_exec.go b/old/backend/internal/library/placement/crawler_exec.go
deleted file mode 100644
index 6a03905..0000000
--- a/old/backend/internal/library/placement/crawler_exec.go
+++ /dev/null
@@ -1,203 +0,0 @@
-package placement
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-// CrawlerSearchFn runs Playwright keyword search with a logged-in browser session.
-type CrawlerSearchFn func(ctx context.Context, member MemberContext, keyword string, limit int) ([]DiscoverPost, error)
-
-type execCrawlerInput struct {
- StorageState string `json:"storage_state"`
- Query string `json:"query"`
- Limit int `json:"limit"`
-}
-
-type execCrawlerPost struct {
- Text string `json:"text"`
- Permalink string `json:"permalink"`
- ExternalID string `json:"externalId"`
- AuthorName string `json:"authorName"`
- LikeCount int `json:"likeCount"`
- ReplyCount int `json:"replyCount"`
- AuthorVerified bool `json:"authorVerified"`
- FollowerCount int `json:"followerCount"`
-}
-
-type execCrawlerOutput struct {
- Posts []execCrawlerPost `json:"posts"`
-}
-
-// RunExecCrawlerSearch invokes the Node Playwright CLI (tsx) for keyword search.
-func RunExecCrawlerSearch(ctx context.Context, storageState, keyword string, limit int) ([]DiscoverPost, error) {
- keyword = strings.TrimSpace(keyword)
- if keyword == "" {
- return nil, nil
- }
- storageState = strings.TrimSpace(storageState)
- if storageState == "" {
- return nil, fmt.Errorf("找不到 Chrome session,請先到連線頁同步 Threads 登入態")
- }
- if limit <= 0 {
- limit = 12
- }
- if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_KEYWORD_SEARCH_URL")); endpoint != "" {
- return runHTTPCrawlerSearch(ctx, endpoint, storageState, keyword, limit)
- }
-
- repoRoot, cliPath, err := resolveKeywordSearchCLI()
- if err != nil {
- return nil, err
- }
-
- payload, err := json.Marshal(execCrawlerInput{
- StorageState: storageState,
- Query: keyword,
- Limit: limit,
- })
- if err != nil {
- return nil, err
- }
-
- runCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
- defer cancel()
-
- cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath)
- cmd.Dir = repoRoot
- cmd.Stdin = bytes.NewReader(payload)
- 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("crawler search failed: %s", msg)
- }
-
- var out execCrawlerOutput
- if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
- return nil, fmt.Errorf("crawler search output parse failed: %w", err)
- }
-
- return execCrawlerPostsToDiscover(out.Posts), nil
-}
-
-func runHTTPCrawlerSearch(ctx context.Context, endpoint, storageState, keyword string, limit int) ([]DiscoverPost, error) {
- payload, err := json.Marshal(execCrawlerInput{StorageState: storageState, Query: keyword, Limit: limit})
- if err != nil {
- return nil, err
- }
- runCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
- defer cancel()
- url := strings.TrimRight(endpoint, "/") + "/search"
- req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- res, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("crawler search service failed: %w", err)
- }
- defer res.Body.Close()
- body, _ := io.ReadAll(res.Body)
- if res.StatusCode < 200 || res.StatusCode >= 300 {
- return nil, fmt.Errorf("crawler search service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
- }
- var out execCrawlerOutput
- if err := json.Unmarshal(body, &out); err != nil {
- return nil, fmt.Errorf("crawler search service output parse failed: %w", err)
- }
- return execCrawlerPostsToDiscover(out.Posts), nil
-}
-
-func execCrawlerPostsToDiscover(items []execCrawlerPost) []DiscoverPost {
- posts := make([]DiscoverPost, 0, len(items))
- for _, item := range items {
- text := strings.TrimSpace(item.Text)
- if text == "" {
- continue
- }
- author := strings.TrimSpace(item.AuthorName)
- permalink := strings.TrimSpace(item.Permalink)
- extID := strings.TrimSpace(item.ExternalID)
- posts = append(posts, DiscoverPost{
- Text: text,
- Permalink: permalink,
- ExternalID: extID,
- Author: author,
- AuthorVerified: item.AuthorVerified,
- FollowerCount: item.FollowerCount,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- Source: DiscoverCrawler,
- })
- }
- return posts
-}
-
-func resolveKeywordSearchCLI() (repoRoot, cliPath string, err error) {
- if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" {
- cli := filepath.Join(root, "haixun-backend", "worker", "threads-keyword-search-cli.ts")
- if fileExists(cli) {
- return root, cli, nil
- }
- }
-
- cwd, err := os.Getwd()
- if err != nil {
- return "", "", fmt.Errorf("resolve crawler cli: %w", err)
- }
- dir := cwd
- for i := 0; i < 6; i++ {
- cli := filepath.Join(dir, "haixun-backend", "worker", "threads-keyword-search-cli.ts")
- if fileExists(cli) {
- return dir, cli, nil
- }
- cli = filepath.Join(dir, "worker", "threads-keyword-search-cli.ts")
- if fileExists(cli) {
- return dir, cli, nil
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- break
- }
- dir = parent
- }
- return "", "", fmt.Errorf("找不到 threads-keyword-search-cli.ts,請設定 HAIXUN_REPO_ROOT")
-}
-
-func fileExists(path string) bool {
- info, err := os.Stat(path)
- return err == nil && !info.IsDir()
-}
-
-// CrawlerKeywordFromQuery extracts plain keyword from Brave-style query strings.
-func CrawlerKeywordFromQuery(query, keyword string) string {
- if k := strings.TrimSpace(keyword); k != "" {
- return k
- }
- q := strings.TrimSpace(query)
- q = strings.TrimPrefix(q, "site:threads.net ")
- q = strings.Trim(q, `"`)
- if idx := strings.Index(q, " after:"); idx > 0 {
- q = strings.TrimSpace(q[:idx])
- }
- q = strings.Trim(q, `"`)
- if idx := strings.Index(q, " 請問"); idx > 0 {
- q = strings.TrimSpace(q[:idx])
- }
- return strings.Trim(q, `"`)
-}
diff --git a/old/backend/internal/library/placement/crawler_exec_test.go b/old/backend/internal/library/placement/crawler_exec_test.go
deleted file mode 100644
index 945c4d9..0000000
--- a/old/backend/internal/library/placement/crawler_exec_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package placement
-
-import "testing"
-
-func TestCrawlerKeywordFromQuery(t *testing.T) {
- got := CrawlerKeywordFromQuery(`site:threads.net "敏感肌" 請問 after:2026-01-01`, "")
- if got != "敏感肌" {
- t.Fatalf("keyword = %q, want 敏感肌", got)
- }
- got = CrawlerKeywordFromQuery("", "換季泛紅")
- if got != "換季泛紅" {
- t.Fatalf("keyword = %q, want 換季泛紅", got)
- }
-}
diff --git a/old/backend/internal/library/placement/crawler_polite.go b/old/backend/internal/library/placement/crawler_polite.go
deleted file mode 100644
index 5331854..0000000
--- a/old/backend/internal/library/placement/crawler_polite.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package placement
-
-import (
- "context"
- "math/rand"
- "sync"
- "time"
-)
-
-const (
- CrawlerMinQueryInterval = 8 * time.Second
- CrawlerMaxQueryJitter = 4 * time.Second
-)
-
-// WrapPoliteCrawler spaces out Playwright keyword searches to reduce Threads rate limits.
-func WrapPoliteCrawler(inner CrawlerSearchFn) CrawlerSearchFn {
- if inner == nil {
- return nil
- }
- guard := &crawlerPacing{inner: inner}
- return guard.search
-}
-
-type crawlerPacing struct {
- inner CrawlerSearchFn
- mu sync.Mutex
- last time.Time
-}
-
-func (p *crawlerPacing) search(ctx context.Context, member MemberContext, keyword string, limit int) ([]DiscoverPost, error) {
- p.mu.Lock()
- defer p.mu.Unlock()
-
- if !p.last.IsZero() {
- elapsed := time.Since(p.last)
- wait := CrawlerMinQueryInterval + jitterDuration(CrawlerMaxQueryJitter) - elapsed
- if wait > 0 {
- timer := time.NewTimer(wait)
- defer timer.Stop()
- select {
- case <-ctx.Done():
- return nil, ctx.Err()
- case <-timer.C:
- }
- }
- }
-
- posts, err := p.inner(ctx, member, keyword, limit)
- p.last = time.Now()
- return posts, err
-}
-
-func jitterDuration(max time.Duration) time.Duration {
- if max <= 0 {
- return 0
- }
- return time.Duration(rand.Int63n(int64(max)))
-}
diff --git a/old/backend/internal/library/placement/discover.go b/old/backend/internal/library/placement/discover.go
deleted file mode 100644
index 845d2cc..0000000
--- a/old/backend/internal/library/placement/discover.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package placement
-
-import (
- "context"
- "fmt"
-)
-
-// DiscoverChannel identifies which backend fulfilled a placement discover query.
-type DiscoverChannel string
-
-const (
- DiscoverThreadsAPI DiscoverChannel = "threads_api"
- DiscoverBrave DiscoverChannel = "brave"
- DiscoverExa DiscoverChannel = "exa"
- DiscoverCrawler DiscoverChannel = "crawler"
-)
-
-// DiscoverRequest is used by scan jobs; expand-graph only uses Brave knowledge_expand.
-type DiscoverRequest struct {
- Query string
- Keyword string // plain tag for crawler; optional
- Recency bool
- Limit int
- Member MemberContext
- Crawler CrawlerSearchFn
-}
-
-type DiscoverPost struct {
- Text string
- Permalink string
- ExternalID string
- Author string
- PostedAt string
- AuthorVerified bool
- FollowerCount int
- LikeCount int
- ReplyCount int
- Source DiscoverChannel
-}
-
-// Discover runs keyword discovery respecting search_source_mode and available connections.
-// Crawler-first modes skip Threads API when the browser session returns posts (saves API quota).
-func Discover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, DiscoverChannel, error) {
- m := req.Member
- if !m.HasDiscoverPath() {
- return nil, "", discoverMissingPathError(m)
- }
-
- if ShouldTryCrawlerFirst(m) {
- posts, err := runCrawlerDiscover(ctx, req)
- if err == nil && len(posts) > 0 {
- return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
- }
- if m.SearchSourceMode == SearchSourceCrawler {
- if err != nil {
- return nil, DiscoverCrawler, err
- }
- return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
- }
- }
-
- if m.AllowsThreadsAPI {
- if !m.ApiConnected {
- if !m.CrawlerFallbackAllowed() {
- return nil, "", fmt.Errorf("正式模式需先完成 Threads API 連線")
- }
- } else {
- posts, err := keywordSearchViaThreadsAPI(ctx, req)
- if err == nil && len(posts) > 0 {
- return posts, DiscoverThreadsAPI, nil
- }
- if err != nil {
- if m.CrawlerFallbackAllowed() {
- cPosts, cErr := runCrawlerDiscover(ctx, req)
- if cErr == nil && len(cPosts) > 0 {
- return maybeEnrichDiscoverPosts(ctx, m, cPosts), DiscoverCrawler, nil
- }
- }
- if !m.AllowsBrave && !m.CrawlerFallbackAllowed() {
- // Optional API field gaps must not fail the whole patrol; return empty for this keyword.
- return []DiscoverPost{}, DiscoverThreadsAPI, nil
- }
- }
- }
- }
-
- if m.AllowsBrave {
- if m.WebSearchAPIKey() == "" {
- if m.CrawlerFallbackAllowed() {
- posts, err := runCrawlerDiscover(ctx, req)
- if err == nil {
- return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
- }
- }
- return nil, "", fmt.Errorf("請在設定頁設定 %s Search API key(跟隨此登入帳號)", m.WebSearchProviderLabel())
- }
- return nil, m.WebSearchDiscoverChannel(), fmt.Errorf("web search threads discover delegated to worker")
- }
-
- if m.CrawlerFallbackAllowed() {
- posts, err := runCrawlerDiscover(ctx, req)
- if err != nil {
- return nil, DiscoverCrawler, err
- }
- return maybeEnrichDiscoverPosts(ctx, m, posts), DiscoverCrawler, nil
- }
-
- return nil, "", fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
-}
-
-func maybeEnrichDiscoverPosts(ctx context.Context, m MemberContext, posts []DiscoverPost) []DiscoverPost {
- if !m.ApiConnected {
- return posts
- }
- return EnrichDiscoverPostsMediaIDs(ctx, m.ThreadsAPIAccessToken, posts)
-}
diff --git a/old/backend/internal/library/placement/discover_for_query_test.go b/old/backend/internal/library/placement/discover_for_query_test.go
deleted file mode 100644
index 1f6b30b..0000000
--- a/old/backend/internal/library/placement/discover_for_query_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package placement
-
-import (
- "context"
- "testing"
-
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/websearch"
-)
-
-type stubWebSearch struct {
- enabled bool
- posts []DiscoverPost
-}
-
-func (s stubWebSearch) Search(ctx context.Context, opts websearch.SearchOptions) (websearch.SearchResponse, error) {
- _ = ctx
- _ = opts
- results := make([]websearch.SearchResult, 0, len(s.posts))
- for _, post := range s.posts {
- results = append(results, websearch.SearchResult{
- Title: post.Text,
- URL: post.Permalink,
- Snippet: "",
- })
- }
- return websearch.SearchResponse{Status: "success", Results: results, Provider: websearch.ProviderBrave}, nil
-}
-
-func (s stubWebSearch) Enabled() bool { return s.enabled }
-func (s stubWebSearch) Provider() websearch.Provider { return websearch.ProviderBrave }
-
-func TestDiscoverForQueryMergesWebSearchWhenBraveEnabled(t *testing.T) {
- input := DualTrackInput{
- Member: MemberContext{
- AllowsBrave: true,
- AllowsThreadsAPI: false,
- SearchSourceMode: SearchSourceBrave,
- WebSearchProvider: string(websearch.ProviderBrave),
- BraveAPIKey: "test-key",
- },
- WebSearch: stubWebSearch{
- enabled: true,
- posts: []DiscoverPost{{
- Text: "最近洗衣精有推薦嗎?皮膚會癢",
- Permalink: "https://www.threads.net/@user1/post/abc123",
- }},
- },
- PatrolContext: PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精"},
- }, nil),
- }
- tq := TagQuery{
- Tag: "洗衣精 推薦",
- Query: `site:threads.net 洗衣精 推薦`,
- Dimension: QueryRelevance,
- }
- posts, channel, err := discoverForQuery(context.Background(), input, tq, 5)
- if err != nil {
- t.Fatalf("discoverForQuery error: %v", err)
- }
- if len(posts) == 0 {
- t.Fatal("expected web search posts to be merged")
- }
- if channel != DiscoverBrave {
- t.Fatalf("expected brave channel, got %s", channel)
- }
-}
diff --git a/old/backend/internal/library/placement/discover_route.go b/old/backend/internal/library/placement/discover_route.go
deleted file mode 100644
index e3c7457..0000000
--- a/old/backend/internal/library/placement/discover_route.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package placement
-
-import (
- "context"
- "fmt"
-)
-
-// ShouldTryCrawlerFirst reports whether discover should attempt Playwright before Threads API
-// to minimize official API calls when a browser session is available.
-func ShouldTryCrawlerFirst(m MemberContext) bool {
- if !m.AllowsCrawler || !m.BrowserConnected || m.CrawlerBlocked() {
- return false
- }
- switch m.SearchSourceMode {
- case SearchSourceCrawler, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
- return true
- case SearchSourceMixed, SearchSourceThreadsBrave:
- return true
- default:
- return false
- }
-}
-
-// CrawlerBlocked returns true when the mode is API-only or web-search-only.
-func (m MemberContext) CrawlerBlocked() bool {
- switch m.SearchSourceMode {
- case SearchSourceThreads, SearchSourceBrave:
- return true
- default:
- return false
- }
-}
-
-// CrawlerFallbackAllowed returns true when crawler may be used after API/web search fails.
-func (m MemberContext) CrawlerFallbackAllowed() bool {
- if !m.BrowserConnected {
- return false
- }
- switch m.SearchSourceMode {
- case SearchSourceThreadsCrawler, SearchSourceBraveCrawler, SearchSourceMixed, SearchSourceThreads, SearchSourceBrave, SearchSourceThreadsBrave:
- return true
- default:
- return m.AllowsCrawler
- }
-}
-
-// HasDiscoverPath reports whether at least one discover backend is configured and connected.
-func (m MemberContext) HasDiscoverPath() bool {
- if m.BrowserConnected {
- return true
- }
- if m.AllowsThreadsAPI && m.ApiConnected {
- return true
- }
- if m.AllowsBrave && m.WebSearchAPIKey() != "" {
- return true
- }
- return false
-}
-
-// DiscoverPathLabel summarizes the active routing for job progress UI.
-func (m MemberContext) DiscoverPathLabel() string {
- if ShouldTryCrawlerFirst(m) {
- if m.AllowsThreadsAPI && m.ApiConnected {
- return "爬蟲優先(不足再 API)"
- }
- return "爬蟲"
- }
- if m.SearchSourceMode == SearchSourceCrawler {
- return "爬蟲"
- }
- if m.AllowsThreadsAPI && m.ApiConnected {
- return "Threads API"
- }
- if m.AllowsBrave {
- return m.WebSearchProviderLabel()
- }
- return string(m.SearchSourceMode)
-}
-
-func discoverMissingPathError(m MemberContext) error {
- if m.BrowserConnected {
- return fmt.Errorf("Chrome Session 已同步,可使用爬蟲海巡;請重新整理後再試")
- }
- switch m.SearchSourceMode {
- case SearchSourceCrawler:
- return fmt.Errorf("請先同步 Chrome Session 以使用爬蟲搜尋")
- case SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
- if !m.BrowserConnected && !m.ApiConnected && m.WebSearchAPIKey() == "" {
- return fmt.Errorf("請同步 Chrome Session 或完成 Threads API / Web Search 連線")
- }
- if !m.BrowserConnected {
- return fmt.Errorf("爬蟲優先模式建議先同步 Chrome Session;亦可改用僅 Threads API")
- }
- return fmt.Errorf("請完成 Threads API 或 Web Search 連線作為備援")
- case SearchSourceThreads, SearchSourceThreadsBrave:
- return fmt.Errorf("請先完成 Threads API 連線")
- case SearchSourceBrave:
- return fmt.Errorf("請在設定頁設定 %s Search API key", m.WebSearchProviderLabel())
- case SearchSourceMixed:
- return fmt.Errorf("請同步 Chrome Session、完成 Threads API 或設定 Web Search API key 至少一項")
- default:
- return fmt.Errorf("目前搜尋來源模式無可用管道:%s", m.SearchSourceMode)
- }
-}
-
-func runCrawlerDiscover(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, error) {
- if req.Crawler == nil {
- return nil, fmt.Errorf("crawler search not configured")
- }
- keyword := CrawlerKeywordFromQuery(req.Query, req.Keyword)
- if keyword == "" {
- return nil, fmt.Errorf("crawler keyword is empty")
- }
- return req.Crawler(ctx, req.Member, keyword, req.Limit)
-}
diff --git a/old/backend/internal/library/placement/discover_route_test.go b/old/backend/internal/library/placement/discover_route_test.go
deleted file mode 100644
index d199964..0000000
--- a/old/backend/internal/library/placement/discover_route_test.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package placement
-
-import "testing"
-
-func TestShouldTryCrawlerFirst_mixedWithBrowser(t *testing.T) {
- m := MemberContext{
- AllowsCrawler: true,
- BrowserConnected: true,
- SearchSourceMode: SearchSourceMixed,
- AllowsThreadsAPI: true,
- ApiConnected: true,
- }
- if !ShouldTryCrawlerFirst(m) {
- t.Fatal("mixed + browser should try crawler first to save API")
- }
-}
-
-func TestShouldTryCrawlerFirst_threadsOnly(t *testing.T) {
- m := MemberContext{
- AllowsCrawler: true,
- BrowserConnected: true,
- SearchSourceMode: SearchSourceThreads,
- }
- if ShouldTryCrawlerFirst(m) {
- t.Fatal("threads-only must not use crawler first")
- }
-}
-
-func TestSessionIsDiscoverPathEvenWhenModeIsThreadsOnly(t *testing.T) {
- m := MemberContext{
- BrowserConnected: true,
- SearchSourceMode: SearchSourceThreads,
- }
- if !m.HasDiscoverPath() {
- t.Fatal("browser session should be enough for discover capability")
- }
- if !m.CrawlerFallbackAllowed() {
- t.Fatal("browser session should be available as crawler fallback")
- }
-}
-
-func TestBuildMemberContextFormalModeKeepsCrawlerMode(t *testing.T) {
- prefs := ConnectionPrefsInput{
- DevMode: false,
- SearchSourceMode: string(SearchSourceThreadsCrawler),
- }
- ctx := BuildMemberContext("t", "u", "acc", prefs, true, true, ResearchSettings{}, false, 10)
- if !ctx.AllowsCrawler {
- t.Fatal("threads_crawler in formal mode should allow crawler")
- }
- if !ctx.AllowsThreadsAPI {
- t.Fatal("threads_crawler should still allow API fallback")
- }
-}
diff --git a/old/backend/internal/library/placement/dual_track.go b/old/backend/internal/library/placement/dual_track.go
deleted file mode 100644
index bedbaae..0000000
--- a/old/backend/internal/library/placement/dual_track.go
+++ /dev/null
@@ -1,610 +0,0 @@
-package placement
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/websearch"
-)
-
-const (
- relevanceLimitPerTag = 12
- recencyLimitPerTag = 8
-)
-
-type ScanCandidate struct {
- Permalink string
- ExternalID string
- Author string
- AuthorID string
- AuthorAvatar string
- Text string
- SearchTag string
- QueryDimension QueryDimension
- RecencyDays int
- GraphNodeID string
- ProductFitScore int
- Source DiscoverChannel
- HasRelevance bool
- HasRecency bool
- Priority string
- AuthorVerified bool
- FollowerCount int
- AuthorFollowers int
- LikeCount int
- ReplyCount int
- EngagementScore int
- PlacementScore int
- SolvedByProduct bool
- PostedAt string
- Replies []ReplyCandidate
- Embedding []float32
- SemanticScore int
- EngagementPredicted int
- AudienceQualityScore int
-}
-
-type DualTrackInput struct {
- Nodes []libkg.Node
- PatrolKeywords []string
- Exclusions []string
- PatrolContext PostScanContext
- Member MemberContext
- WebSearch websearch.Client
- Crawler CrawlerSearchFn
- Limit int // max queries budget; 0 = default
- OnCheckpoint func(candidates []ScanCandidate) error
-}
-
-type DualTrackProgress func(message string, pct int)
-
-// CollectTagQueries builds crawl jobs from selected graph nodes.
-func CollectTagQueries(nodes []libkg.Node, provider websearch.Provider) []TagQuery {
- out := make([]TagQuery, 0, len(nodes)*4)
- for _, node := range nodes {
- if !node.SelectedForScan {
- continue
- }
- fit := node.ProductFitScore
- derived := node.DerivedTags
- if len(derived.Relevance) == 0 && len(derived.Recency) == 0 {
- derived = libkg.DerivePatrolTagsForNode(node, libkg.PatrolTagInput{})
- }
- for _, tag := range derived.Relevance {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- continue
- }
- q := BuildRelevanceQuery(provider, tag)
- if q == "" {
- continue
- }
- out = append(out, TagQuery{
- Tag: tag,
- Query: q,
- Dimension: QueryRelevance,
- GraphNodeID: node.ID,
- ProductFitScore: fit,
- })
- }
- for _, tag := range derived.Recency {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- continue
- }
- q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays)
- if q7 != "" {
- out = append(out, TagQuery{
- Tag: tag,
- Query: q7,
- Dimension: QueryRecency,
- GraphNodeID: node.ID,
- ProductFitScore: fit,
- RecencyDays: IdealMaxPostAgeDays,
- })
- }
- }
- }
- return out
-}
-
-// RunDualTrackDiscover executes relevance + recency queries and merges by permalink.
-func RunDualTrackDiscover(ctx context.Context, input DualTrackInput, onProgress DualTrackProgress) ([]ScanCandidate, error) {
- queries := ResolveTagQueries(
- input.Nodes,
- input.PatrolKeywords,
- input.Member.WebSearchProviderEnum(),
- PatrolTagInputFromScanContext(input.PatrolContext),
- )
- if len(queries) == 0 {
- if len(input.PatrolKeywords) > 0 {
- return nil, fmt.Errorf("海巡關鍵字格式無效,請改用 2~8 字的真人搜尋短句")
- }
- selected := 0
- for _, node := range input.Nodes {
- if node.SelectedForScan {
- selected++
- }
- }
- if selected > 0 {
- return nil, fmt.Errorf("已勾選節點但沒有可用的海巡 tag,請重新擴展圖譜或手動編輯 tag")
- }
- return nil, fmt.Errorf("請先勾選要海巡的節點並儲存")
- }
-
- merged := map[string]*ScanCandidate{}
- order := make([]string, 0, 64)
-
- runQuery := func(tq TagQuery, limit int) error {
- posts, channel, err := discoverForQuery(ctx, input, tq, limit)
- if err != nil {
- if onProgress != nil {
- onProgress(fmt.Sprintf("略過「%s」:%s", tq.Tag, err.Error()), -1)
- }
- return nil
- }
- if len(posts) == 0 {
- return nil
- }
- for _, post := range posts {
- if MatchesExclusion(post.Text, input.Exclusions) {
- continue
- }
- if LooksLikeCasualChat(post.Text) {
- continue
- }
- postedAt := strings.TrimSpace(post.PostedAt)
- if !PassesDiscoverFilters(post.Text, tq.Tag, postedAt, tq.Dimension, tq.ProductFitScore, tq.RecencyDays, input.PatrolContext) {
- continue
- }
- bodyFit := ScorePostBodyProductFit(post.Text, input.PatrolContext)
- semanticScore := LocalIntentScore(post.Text, input.PatrolContext)
- effectiveFit := bodyFit
- if tq.ProductFitScore > 0 && bodyFit > 0 {
- effectiveFit = (tq.ProductFitScore + bodyFit*2) / 3
- }
- key := post.Permalink
- if key == "" {
- continue
- }
- existing, ok := merged[key]
- if !ok {
- priority := "relevant"
- if tq.Dimension == QueryRecency {
- priority = "recent"
- }
- extID := post.ExternalID
- if extID == "" {
- if parsed, ok := ParseThreadsPostFromWebResult(post.Text, "", post.Permalink); ok {
- extID = parsed.ExternalID
- }
- }
- semP := &semanticScore
- merged[key] = &ScanCandidate{
- Permalink: post.Permalink,
- ExternalID: extID,
- Author: post.Author,
- AuthorVerified: post.AuthorVerified,
- FollowerCount: post.FollowerCount,
- Text: post.Text,
- SearchTag: tq.Tag,
- QueryDimension: tq.Dimension,
- RecencyDays: recencyDaysForCandidate(tq),
- GraphNodeID: tq.GraphNodeID,
- ProductFitScore: effectiveFit,
- Source: channel,
- HasRelevance: tq.Dimension == QueryRelevance,
- HasRecency: tq.Dimension == QueryRecency,
- Priority: priority,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- SemanticScore: semanticScore,
- PlacementScore: computePlacementScore(post.Text, effectiveFit, tq.Dimension == QueryRecency, nil, semP, nil),
- SolvedByProduct: PostSolvedByProduct(post.Text, effectiveFit, input.PatrolContext),
- PostedAt: postedAt,
- }
- order = append(order, key)
- continue
- }
- if tq.Dimension == QueryRelevance {
- existing.HasRelevance = true
- }
- if tq.Dimension == QueryRecency {
- existing.HasRecency = true
- existing.RecencyDays = mergeRecencyDays(existing.RecencyDays, tq.RecencyDays)
- }
- if effectiveFit > existing.ProductFitScore || semanticScore > existing.SemanticScore {
- if effectiveFit > existing.ProductFitScore {
- existing.ProductFitScore = effectiveFit
- }
- if semanticScore > existing.SemanticScore {
- existing.SemanticScore = semanticScore
- }
- existing.SolvedByProduct = PostSolvedByProduct(existing.Text, existing.ProductFitScore, input.PatrolContext)
- semP := existing.SemanticScore
- semPtr := &semP
- if semP <= 0 {
- semPtr = nil
- }
- existing.PlacementScore = computePlacementScore(existing.Text, existing.ProductFitScore, existing.HasRecency, nil, semPtr, nil)
- }
- if strings.TrimSpace(existing.PostedAt) == "" && strings.TrimSpace(post.PostedAt) != "" {
- existing.PostedAt = strings.TrimSpace(post.PostedAt)
- }
- existing.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
- }
- return nil
- }
-
- total := len(queries)
- for i, tq := range queries {
- if onProgress != nil {
- pct := 10 + ((i + 1) * 75 / max(total, 1))
- onProgress(fmt.Sprintf("雙軌海巡 %d/%d:%s", i+1, total, tq.Tag), pct)
- }
- limit := perTagDiscoverLimit(total, tq.Dimension)
- if err := runQuery(tq, limit); err != nil {
- return nil, err
- }
- if input.OnCheckpoint != nil {
- snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
- if err := input.OnCheckpoint(snapshot); err != nil {
- return nil, err
- }
- }
- if input.Member.AllowsCrawler && input.Member.BrowserConnected && i < total-1 {
- if err := politeDiscoverPause(ctx); err != nil {
- return nil, err
- }
- }
- }
-
- cascadeExtra := 0
- for _, st := range buildTagCascadeStates(queries) {
- for countCandidatesForTag(merged, st.Tag) < MinRecencyCandidatesPerTag {
- if cascadeExtra >= MaxRecencyCascadeQueries {
- break
- }
- days := nextRecencyCascadeWindow(st.RanWindows)
- if days == 0 {
- break
- }
- tq, ok := buildRecencyCascadeQuery(st, days, input.Member.WebSearchProviderEnum())
- if !ok {
- st.RanWindows[days] = true
- continue
- }
- if onProgress != nil {
- onProgress(fmt.Sprintf("近 %d 天結果不足,改搜近 %d 天:%s", previousRecencyWindow(days), days, st.Tag), -1)
- }
- limit := perTagDiscoverLimit(total, QueryRecency)
- if err := runQuery(tq, limit); err != nil {
- return nil, err
- }
- st.RanWindows[days] = true
- cascadeExtra++
- if input.OnCheckpoint != nil {
- snapshot := snapshotMergedCandidates(merged, order, false, input.PatrolContext)
- if err := input.OnCheckpoint(snapshot); err != nil {
- return nil, err
- }
- }
- if input.Member.AllowsCrawler && input.Member.BrowserConnected {
- if err := politeDiscoverPause(ctx); err != nil {
- return nil, err
- }
- }
- }
- if cascadeExtra >= MaxRecencyCascadeQueries {
- break
- }
- }
-
- out := snapshotMergedCandidates(merged, order, true, input.PatrolContext)
- if onProgress != nil {
- onProgress(fmt.Sprintf("合併完成,共 %d 篇候選貼文", len(out)), 90)
- }
- return out, nil
-}
-
-func discoverForQuery(ctx context.Context, input DualTrackInput, tq TagQuery, limit int) ([]DiscoverPost, DiscoverChannel, error) {
- apiKeyword := tq.Tag
- if shaped := libkg.ThreadsAPIKeyword(tq.Tag); shaped != "" {
- apiKeyword = shaped
- }
- req := DiscoverRequest{
- Query: tq.Query,
- Keyword: apiKeyword,
- Recency: tq.Dimension == QueryRecency,
- Limit: limit,
- Member: input.Member,
- Crawler: input.Crawler,
- }
- merged := map[string]DiscoverPost{}
- channel := DiscoverChannel("")
- posts, primaryChannel, err := Discover(ctx, req)
- for _, post := range posts {
- key := strings.TrimSpace(post.Permalink)
- if key == "" {
- continue
- }
- if existing, ok := merged[key]; ok {
- post.ExternalID = PreferReplyExternalID(existing.ExternalID, post.ExternalID)
- }
- merged[key] = post
- }
- if primaryChannel != "" {
- channel = primaryChannel
- }
-
- webEnabled := input.WebSearch != nil && input.WebSearch.Enabled()
- if webEnabled && input.Member.AllowsBrave {
- webPosts, werr := discoverViaWebSearch(ctx, input.WebSearch, input.Member, tq, limit)
- if werr != nil && len(merged) == 0 && err != nil {
- return nil, "", err
- }
- for _, post := range webPosts {
- key := strings.TrimSpace(post.Permalink)
- if key == "" {
- continue
- }
- merged[key] = post
- }
- if len(merged) > 0 && channel == "" {
- channel = input.Member.WebSearchDiscoverChannel()
- }
- } else if len(merged) == 0 {
- if err != nil {
- return nil, "", err
- }
- if !webEnabled {
- return nil, "", fmt.Errorf("%s 未設定且 Threads API 無結果", input.Member.WebSearchProviderLabel())
- }
- }
-
- out := make([]DiscoverPost, 0, len(merged))
- for _, post := range merged {
- out = append(out, post)
- }
- if len(out) > limit {
- out = out[:limit]
- }
- if channel == "" && len(out) > 0 {
- channel = DiscoverThreadsAPI
- }
- return out, channel, nil
-}
-
-func discoverViaWebSearch(ctx context.Context, client websearch.Client, member MemberContext, tq TagQuery, limit int) ([]DiscoverPost, error) {
- res, err := client.Search(ctx, websearch.SearchOptions{
- Query: tq.Query,
- Limit: limit,
- Mode: websearch.ModeThreadsDiscover,
- Country: member.BraveCountry,
- SearchLang: member.BraveSearchLang,
- UserLocation: member.ExaUserLocation,
- StartPublishedDate: PublishedAfterForRecency(member.WebSearchProviderEnum(), tq.RecencyDays),
- })
- if err != nil {
- return nil, err
- }
- if res.Status != "success" || len(res.Results) == 0 {
- return nil, nil
- }
- source := member.WebSearchDiscoverChannel()
- out := make([]DiscoverPost, 0, len(res.Results))
- for _, item := range res.Results {
- parsed, ok := ParseThreadsPostFromWebResult(item.Title, item.Snippet, item.URL)
- if !ok {
- continue
- }
- out = append(out, DiscoverPost{
- Text: parsed.Text,
- Permalink: parsed.Permalink,
- ExternalID: parsed.ExternalID,
- Author: parsed.Author,
- Source: source,
- })
- }
- return out, nil
-}
-
-func snapshotMergedCandidates(merged map[string]*ScanCandidate, order []string, applyFinalFilter bool, ctx PostScanContext) []ScanCandidate {
- out := make([]ScanCandidate, 0, len(order))
- for _, key := range order {
- item := merged[key]
- finalizeScanCandidate(item, ctx)
- if applyFinalFilter && !passesFinalStrictFilter(item) {
- continue
- }
- out = append(out, *item)
- }
- if applyFinalFilter && len(out) == 0 && len(order) > 0 {
- for _, key := range order {
- item := merged[key]
- finalizeScanCandidate(item, ctx)
- if !PassesRelaxedFinalFilters(item.Text, item.ProductFitScore, ctx) {
- continue
- }
- out = append(out, *item)
- }
- }
- if applyFinalFilter && len(out) == 0 && len(order) > 0 {
- for _, key := range order {
- item := merged[key]
- finalizeScanCandidate(item, ctx)
- if !PassesIngestFallbackFilters(item.Text, item.ProductFitScore, ctx) {
- continue
- }
- out = append(out, *item)
- }
- }
- return out
-}
-
-func passesFinalStrictFilter(item *ScanCandidate) bool {
- if item == nil {
- return false
- }
- if item.ProductFitScore < minPostProductFitPatrol && item.Priority != "gold" {
- return false
- }
- return item.SolvedByProduct
-}
-
-func finalizeScanCandidate(item *ScanCandidate, ctx PostScanContext) {
- if item == nil {
- return
- }
- if item.HasRelevance && item.HasRecency && item.ProductFitScore >= 45 {
- item.Priority = "gold"
- } else if item.HasRecency {
- item.Priority = "recent"
- } else {
- item.Priority = "relevant"
- }
- var engP, semP, qualP *int
- if item.EngagementPredicted > 0 {
- v := item.EngagementPredicted
- engP = &v
- }
- if item.SemanticScore > 0 {
- v := item.SemanticScore
- semP = &v
- }
- if item.AudienceQualityScore > 0 {
- v := item.AudienceQualityScore
- qualP = &v
- }
- item.PlacementScore = computePlacementScore(item.Text, item.ProductFitScore, item.HasRecency, engP, semP, qualP)
- item.SolvedByProduct = PostSolvedByProduct(item.Text, item.ProductFitScore, ctx)
-}
-
-func computePlacementScore(text string, productFit int, recent bool, predictedEngagement *int, semanticScore *int, audienceQuality *int) int {
- score := 30 + productFit/4
- if HasPlacementIntent(text) {
- score += 20
- }
- if LooksLikeRecommendationPost(text) {
- score += 12
- }
- if recent {
- score += 15
- }
- if productFit >= 60 {
- score += 8
- }
- if predictedEngagement != nil && *predictedEngagement > 60 {
- score += 10
- }
- if semanticScore != nil && *semanticScore > 50 {
- score += 10
- }
- if audienceQuality != nil && *audienceQuality > 60 {
- score += 5
- }
- if score > 100 {
- return 100
- }
- return score
-}
-
-func computeAudienceQuality(followerCount int, authorVerified bool, likeCount int, replyCount int) int {
- score := 30
- if authorVerified {
- score += 20
- }
- if followerCount > 10000 {
- score += 15
- } else if followerCount > 1000 {
- score += 10
- } else if followerCount > 100 {
- score += 5
- }
- totalEngagement := likeCount + replyCount*3
- if followerCount > 0 && totalEngagement > 0 {
- engagementRate := (totalEngagement * 100) / followerCount
- if engagementRate > 5 {
- score += 15
- } else if engagementRate > 2 {
- score += 10
- } else if engagementRate > 1 {
- score += 5
- }
- }
- if score > 100 {
- return 100
- }
- return score
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
-func recencyDaysForCandidate(tq TagQuery) int {
- if tq.Dimension != QueryRecency {
- return 0
- }
- if tq.RecencyDays > 0 {
- return tq.RecencyDays
- }
- return IdealMaxPostAgeDays
-}
-
-func mergeRecencyDays(existing, incoming int) int {
- if incoming <= 0 {
- return existing
- }
- if existing <= 0 {
- return incoming
- }
- if incoming < existing {
- return incoming
- }
- return existing
-}
-
-func previousRecencyWindow(days int) int {
- prev := IdealMaxPostAgeDays
- for _, window := range RecencyCascadeDays() {
- if window == days {
- return prev
- }
- prev = window
- }
- return IdealMaxPostAgeDays
-}
-
-func perTagDiscoverLimit(totalQueries int, dimension QueryDimension) int {
- limit := relevanceLimitPerTag
- if dimension == QueryRecency {
- limit = recencyLimitPerTag
- }
- switch {
- case totalQueries > 18:
- return max(7, limit-3)
- case totalQueries > 14:
- return max(8, limit-2)
- default:
- return limit
- }
-}
-
-func politeDiscoverPause(ctx context.Context) error {
- wait := 2*time.Second + jitterDuration(2*time.Second)
- timer := time.NewTimer(wait)
- defer timer.Stop()
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-timer.C:
- return nil
- }
-}
diff --git a/old/backend/internal/library/placement/effective_strategy.go b/old/backend/internal/library/placement/effective_strategy.go
deleted file mode 100644
index 7c7795c..0000000
--- a/old/backend/internal/library/placement/effective_strategy.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package placement
-
-import libkg "haixun-backend/internal/library/knowledge"
-
-// EffectiveExpandStrategy returns LLM when web search is required but no API key is configured.
-func EffectiveExpandStrategy(research ResearchSettings) libkg.ExpandStrategy {
- strategy := libkg.ParseExpandStrategy(research.ExpandStrategy)
- if strategy.RequiresWebSearch() && MissingWebSearchKey(research) {
- return libkg.ExpandStrategyLLM
- }
- return strategy
-}
-
-func WebSearchAvailable(research ResearchSettings) bool {
- return !MissingWebSearchKey(research)
-}
diff --git a/old/backend/internal/library/placement/effective_strategy_test.go b/old/backend/internal/library/placement/effective_strategy_test.go
deleted file mode 100644
index 228950a..0000000
--- a/old/backend/internal/library/placement/effective_strategy_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package placement
-
-import (
- "testing"
-
- libkg "haixun-backend/internal/library/knowledge"
-)
-
-func TestEffectiveExpandStrategyFallsBackToLLMWithoutKey(t *testing.T) {
- research := ResearchSettings{
- ExpandStrategy: string(libkg.ExpandStrategyBrave),
- WebSearchProvider: "brave",
- BraveAPIKey: "",
- }
- if got := EffectiveExpandStrategy(research); got != libkg.ExpandStrategyLLM {
- t.Fatalf("expected llm fallback, got %s", got)
- }
-}
-
-func TestEffectiveExpandStrategyKeepsBraveWithKey(t *testing.T) {
- research := ResearchSettings{
- ExpandStrategy: string(libkg.ExpandStrategyBrave),
- WebSearchProvider: "brave",
- BraveAPIKey: "test-key",
- }
- if got := EffectiveExpandStrategy(research); got != libkg.ExpandStrategyBrave {
- t.Fatalf("expected brave, got %s", got)
- }
-}
-
-func TestWebSearchAvailable(t *testing.T) {
- if WebSearchAvailable(ResearchSettings{WebSearchProvider: "brave"}) {
- t.Fatal("expected unavailable without brave key")
- }
- if !WebSearchAvailable(ResearchSettings{WebSearchProvider: "brave", BraveAPIKey: "k"}) {
- t.Fatal("expected available with brave key")
- }
-}
diff --git a/old/backend/internal/library/placement/exclusion.go b/old/backend/internal/library/placement/exclusion.go
deleted file mode 100644
index ea89315..0000000
--- a/old/backend/internal/library/placement/exclusion.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package placement
-
-import "strings"
-
-func MatchesExclusion(text string, exclusions []string) bool {
- text = strings.ToLower(strings.TrimSpace(text))
- if text == "" || len(exclusions) == 0 {
- return false
- }
- for _, rule := range exclusions {
- rule = strings.ToLower(strings.TrimSpace(rule))
- if rule == "" {
- continue
- }
- if strings.Contains(text, rule) {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/placement/external_id.go b/old/backend/internal/library/placement/external_id.go
deleted file mode 100644
index 1f52c8b..0000000
--- a/old/backend/internal/library/placement/external_id.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package placement
-
-import (
- "context"
- "strings"
-
- libthreads "haixun-backend/internal/library/threadsapi"
-)
-
-// PreferReplyExternalID keeps a numeric Threads media id when merging scan rows.
-func PreferReplyExternalID(existing, incoming string) string {
- a := strings.TrimSpace(existing)
- b := strings.TrimSpace(incoming)
- if libthreads.IsNumericMediaID(a) {
- return a
- }
- if libthreads.IsNumericMediaID(b) {
- return b
- }
- if b != "" {
- return b
- }
- return a
-}
-
-// EnrichDiscoverPostsMediaIDs resolves shortcode-only crawler rows via Threads API when possible.
-func EnrichDiscoverPostsMediaIDs(ctx context.Context, accessToken string, posts []DiscoverPost) []DiscoverPost {
- token := strings.TrimSpace(accessToken)
- if token == "" || len(posts) == 0 {
- return posts
- }
- client := libthreads.NewClient(token)
- out := make([]DiscoverPost, len(posts))
- copy(out, posts)
- for i := range out {
- if libthreads.IsNumericMediaID(out[i].ExternalID) {
- continue
- }
- id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{
- ExternalID: out[i].ExternalID,
- Permalink: out[i].Permalink,
- HintText: out[i].Text,
- })
- if err != nil || !libthreads.IsNumericMediaID(id) {
- continue
- }
- out[i].ExternalID = id
- }
- return out
-}
diff --git a/old/backend/internal/library/placement/external_id_test.go b/old/backend/internal/library/placement/external_id_test.go
deleted file mode 100644
index 03db367..0000000
--- a/old/backend/internal/library/placement/external_id_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package placement
-
-import "testing"
-
-func TestPreferReplyExternalIDKeepsNumeric(t *testing.T) {
- got := PreferReplyExternalID("18123456789012345", "AbCdEf123")
- if got != "18123456789012345" {
- t.Fatalf("got %q", got)
- }
- got = PreferReplyExternalID("AbCdEf123", "18123456789012345")
- if got != "18123456789012345" {
- t.Fatalf("got %q", got)
- }
-}
diff --git a/old/backend/internal/library/placement/filter.go b/old/backend/internal/library/placement/filter.go
deleted file mode 100644
index 4fbdc31..0000000
--- a/old/backend/internal/library/placement/filter.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package placement
-
-import "regexp"
-
-var (
- placementRecommendRe = regexp.MustCompile(`推薦|求助|請益|請問|哪裡買|有沒有|求分享|困擾|煩惱|怎麼辦|怎麼選|不知道|有推|拜託|求救|卡關`)
- placementIntentRe = regexp.MustCompile(`用什麼洗|哪款|哪牌|哪一牌|洗什麼|買什麼|在家洗|自己洗|洗澡怕|洗不乾淨|味道重|皮膚癢|皮膚紅|一直抓|掉毛多|敏感肌|過敏|紅腫|抓癢|異味|不敢洗|第一次洗|洗完還是|越洗越`)
- painProblemRe = regexp.MustCompile(`痛|癢|乾|紅|腫|過敏|困擾|煩惱|難搞|不好用|沒效|無效|洗不掉|洗不乾淨|洗不乾|臭|異味|異味重|怕|擔心|問題|不舒服|刺激|脫皮|粗糙|暗沉|掉屑|掉毛|污漬|油漬|發炎`)
- casualChatRe = regexp.MustCompile(`好可愛|太萌|晒照|日常分享|隨便發|廢文|路過|笑死|哈哈哈|哈囉|早安|晚安|按讚|追蹤我|純分享|沒有要問`)
-)
-
-func LooksLikeRecommendationPost(text string) bool {
- return placementRecommendRe.MatchString(text)
-}
-
-func HasPlacementIntent(text string) bool {
- if LooksLikeRecommendationPost(text) {
- return true
- }
- return placementIntentRe.MatchString(text)
-}
-
-func LooksLikeCasualChat(text string) bool {
- if HasPlacementIntent(text) {
- return false
- }
- return casualChatRe.MatchString(text)
-}
-
-func PassesPlacementFilter(text string) bool {
- if text == "" {
- return false
- }
- if LooksLikeCasualChat(text) {
- return false
- }
- return HasPlacementIntent(text)
-}
-
-// HasPainPointDemand requires the post to read like someone asking for help with a solvable problem.
-func HasPainPointDemand(text string) bool {
- if !PassesPlacementFilter(text) {
- return false
- }
- return painProblemRe.MatchString(text) || LooksLikeRecommendationPost(text)
-}
diff --git a/old/backend/internal/library/placement/filter_test.go b/old/backend/internal/library/placement/filter_test.go
deleted file mode 100644
index a11fb70..0000000
--- a/old/backend/internal/library/placement/filter_test.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package placement
-
-import "testing"
-
-func TestPassesPlacementFilter(t *testing.T) {
- if !PassesPlacementFilter("敏感肌請問有推薦的洗臉產品嗎") {
- t.Fatal("expected placement intent")
- }
- if PassesPlacementFilter("今天天氣真好晒照") {
- t.Fatal("expected casual chat rejection")
- }
-}
diff --git a/old/backend/internal/library/placement/media_resolve.go b/old/backend/internal/library/placement/media_resolve.go
deleted file mode 100644
index bc4b3c3..0000000
--- a/old/backend/internal/library/placement/media_resolve.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package placement
-
-import (
- "context"
- "strings"
-
- libthreads "haixun-backend/internal/library/threadsapi"
-)
-
-type ReplyMediaResolveInput struct {
- AccessToken string
- StorageState string
- ExternalID string
- Permalink string
- HintText string
-}
-
-// ResolveReplyMediaWithFallback resolves a Threads reply target id via API and permalink scraping.
-func ResolveReplyMediaWithFallback(ctx context.Context, in ReplyMediaResolveInput) (string, error) {
- return resolveReplyMedia(ctx, in, false)
-}
-
-// ResolveReplyMediaForPublish prefers fast permalink scraping, then API lookup.
-func ResolveReplyMediaForPublish(ctx context.Context, in ReplyMediaResolveInput) (string, error) {
- return resolveReplyMedia(ctx, in, true)
-}
-
-func resolveReplyMedia(ctx context.Context, in ReplyMediaResolveInput, publishFastPath bool) (string, error) {
- if libthreads.IsNumericMediaID(in.ExternalID) {
- return strings.TrimSpace(in.ExternalID), nil
- }
-
- permalink := strings.TrimSpace(in.Permalink)
- if publishFastPath && permalink != "" {
- if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) {
- return scraped, nil
- }
- }
-
- client := libthreads.NewClient(in.AccessToken)
- id, err := client.ResolveReplyMedia(ctx, libthreads.ResolveReplyMediaInput{
- ExternalID: in.ExternalID,
- Permalink: in.Permalink,
- HintText: in.HintText,
- })
- if err == nil && libthreads.IsNumericMediaID(id) {
- return id, nil
- }
-
- if !publishFastPath && permalink != "" {
- if scraped, sErr := RunResolveMediaFromPermalink(ctx, permalink, in.StorageState); sErr == nil && libthreads.IsNumericMediaID(scraped) {
- return scraped, nil
- }
- }
-
- return "", err
-}
diff --git a/old/backend/internal/library/placement/parse_threads.go b/old/backend/internal/library/placement/parse_threads.go
deleted file mode 100644
index 740d867..0000000
--- a/old/backend/internal/library/placement/parse_threads.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package placement
-
-import (
- "regexp"
- "strings"
-)
-
-var threadsPostURLRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`)
-
-type ParsedThreadsPost struct {
- Permalink string
- ExternalID string
- Author string
- Text string
-}
-
-func ParseThreadsPostFromWebResult(title, snippet, url string) (ParsedThreadsPost, bool) {
- permalink := normalizeThreadsPermalink(url)
- if permalink == "" {
- return ParsedThreadsPost{}, false
- }
- match := threadsPostURLRe.FindStringSubmatch(permalink)
- if len(match) < 3 {
- return ParsedThreadsPost{}, false
- }
- text := strings.TrimSpace(strings.Join(filterNonEmpty([]string{strings.TrimSpace(title), strings.TrimSpace(snippet)}), " — "))
- if text == "" {
- text = strings.TrimSpace(title)
- }
- if text == "" {
- text = strings.TrimSpace(snippet)
- }
- if len([]rune(text)) < 6 {
- return ParsedThreadsPost{}, false
- }
- return ParsedThreadsPost{
- Permalink: permalink,
- ExternalID: match[2],
- Author: match[1],
- Text: text,
- }, true
-}
-
-func filterNonEmpty(items []string) []string {
- out := make([]string, 0, len(items))
- for _, item := range items {
- if strings.TrimSpace(item) != "" {
- out = append(out, item)
- }
- }
- return out
-}
-
-func normalizeThreadsPermalink(raw string) string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ""
- }
- raw = strings.Split(raw, "?")[0]
- raw = strings.Split(raw, "#")[0]
- if threadsPostURLRe.MatchString(raw) {
- return raw
- }
- return ""
-}
diff --git a/old/backend/internal/library/placement/patrol_queries.go b/old/backend/internal/library/placement/patrol_queries.go
deleted file mode 100644
index cc2d851..0000000
--- a/old/backend/internal/library/placement/patrol_queries.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package placement
-
-import (
- "sort"
- "strings"
-
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/websearch"
-)
-
-const (
- defaultPatrolProductFitNoGraph = 42
- maxPatrolQueriesPerScan = libkg.MaxScanPatrolKeywords * 2
-)
-
-// CollectPatrolTagQueries builds lean dual-track jobs: relevance + 7d recency per keyword.
-func CollectPatrolTagQueries(keywords []string, nodes []libkg.Node, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
- keywords = libkg.NormalizePatrolKeywordList(keywords)
- if len(keywords) == 0 {
- return nil
- }
- if len(keywords) > libkg.MaxScanPatrolKeywords {
- keywords = keywords[:libkg.MaxScanPatrolKeywords]
- }
-
- out := make([]TagQuery, 0, len(keywords)*2)
- for _, tag := range keywords {
- fit := productFitForPatrolTag(tag, nodes, patrolInput)
- if q := BuildRelevanceQuery(provider, tag); q != "" {
- out = append(out, TagQuery{
- Tag: tag,
- Query: q,
- Dimension: QueryRelevance,
- ProductFitScore: fit,
- })
- }
- if q7 := BuildRecencyQuery(provider, tag, IdealMaxPostAgeDays); q7 != "" {
- out = append(out, TagQuery{
- Tag: tag,
- Query: q7,
- Dimension: QueryRecency,
- ProductFitScore: fit,
- RecencyDays: IdealMaxPostAgeDays,
- })
- }
- }
- return out
-}
-
-func productFitForPatrolTag(tag string, nodes []libkg.Node, patrolInput libkg.PatrolTagInput) int {
- tagKey := patrolTagMatchKey(tag)
- best := 0
- profile := libkg.BuildIntentProfile(patrolInput)
- intentFit := libkg.ScoreIntentSimilarity(tag, profile)
- for _, node := range nodes {
- score := node.ProductFitScore
- if score <= 0 {
- continue
- }
- matched := false
- for _, candidate := range append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...) {
- if patrolTagMatchKey(candidate) == tagKey {
- matched = true
- break
- }
- }
- if !matched && patrolTagMatchKey(node.Label) != tagKey {
- continue
- }
- if score > best {
- best = score
- }
- }
- if best > 0 {
- return maxPatrolFit(best, intentFit)
- }
- if intentFit > 0 {
- return intentFit
- }
- return defaultPatrolProductFitNoGraph
-}
-
-func maxPatrolFit(values ...int) int {
- best := 0
- for _, v := range values {
- if v > best {
- best = v
- }
- }
- return best
-}
-
-func patrolTagMatchKey(tag string) string {
- tag = strings.TrimSpace(tag)
- for _, suffix := range []string{" 推薦", " 請問", " 怎麼辦", " 好用嗎", " 有人用過嗎", " 有推薦嗎", " 請益"} {
- if strings.HasSuffix(tag, suffix) {
- tag = strings.TrimSuffix(tag, suffix)
- break
- }
- }
- return strings.TrimSpace(tag)
-}
-
-// ResolveTagQueries builds Search API jobs from the best-ranked patrol keywords.
-func ResolveTagQueries(nodes []libkg.Node, patrolKeywords []string, provider websearch.Provider, patrolInput libkg.PatrolTagInput) []TagQuery {
- nodes = libkg.NodesForPatrolKeywordDerivation(nodes)
- keywords := patrolKeywords
- if len(keywords) == 0 {
- keywords = libkg.SelectBestSearchKeywords(nil, nil, patrolInput, nodes, libkg.MaxScanPatrolKeywords)
- }
- queries := CollectPatrolTagQueries(keywords, nodes, provider, patrolInput)
- if len(queries) == 0 {
- queries = CollectTagQueries(nodes, provider)
- }
- return trimPatrolQueries(queries, maxPatrolQueriesPerScan, patrolInput)
-}
-
-func trimPatrolQueries(queries []TagQuery, max int, patrolInput libkg.PatrolTagInput) []TagQuery {
- profile := libkg.BuildIntentProfile(patrolInput)
- if max <= 0 || len(queries) <= max {
- return queries
- }
- type ranked struct {
- query TagQuery
- key string
- }
- rankedQueries := make([]ranked, 0, len(queries))
- seen := map[string]struct{}{}
- for _, item := range queries {
- key := strings.TrimSpace(item.Query)
- if key == "" {
- continue
- }
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- rankedQueries = append(rankedQueries, ranked{query: item, key: key})
- }
- sort.SliceStable(rankedQueries, func(i, j int) bool {
- left := rankedQueries[i].query.ProductFitScore
- right := rankedQueries[j].query.ProductFitScore
- leftIntent := libkg.ScoreIntentSimilarity(rankedQueries[i].query.Tag, profile)
- rightIntent := libkg.ScoreIntentSimilarity(rankedQueries[j].query.Tag, profile)
- leftRank := left*2 + leftIntent*3
- rightRank := right*2 + rightIntent*3
- if leftRank == rightRank {
- if rankedQueries[i].query.Dimension == rankedQueries[j].query.Dimension {
- return rankedQueries[i].key < rankedQueries[j].key
- }
- if rankedQueries[i].query.Dimension == QueryRelevance {
- return true
- }
- return false
- }
- return leftRank > rightRank
- })
- out := make([]TagQuery, 0, max)
- for _, item := range rankedQueries {
- out = append(out, item.query)
- if len(out) >= max {
- break
- }
- }
- return out
-}
diff --git a/old/backend/internal/library/placement/patrol_queries_test.go b/old/backend/internal/library/placement/patrol_queries_test.go
deleted file mode 100644
index cc8dc10..0000000
--- a/old/backend/internal/library/placement/patrol_queries_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package placement
-
-import (
- "testing"
-
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/websearch"
-)
-
-func TestCollectPatrolTagQueriesManualOnly(t *testing.T) {
- queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nil, websearch.ProviderBrave, libkg.PatrolTagInput{})
- if len(queries) < 2 {
- t.Fatalf("expected relevance + recency queries, got %d", len(queries))
- }
- if queries[0].Tag != "化療 沐浴乳" || queries[0].Dimension != QueryRelevance {
- t.Fatalf("unexpected first query: %+v", queries[0])
- }
- if queries[0].ProductFitScore != defaultPatrolProductFitNoGraph {
- t.Fatalf("expected default fit %d, got %d", defaultPatrolProductFitNoGraph, queries[0].ProductFitScore)
- }
-}
-
-func TestCollectPatrolTagQueriesUsesGraphFit(t *testing.T) {
- nodes := []libkg.Node{
- {
- ID: "n1",
- Label: "化療 沐浴乳",
- ProductFitScore: 92,
- DerivedTags: libkg.DerivedTags{
- Relevance: []string{"化療 沐浴乳"},
- },
- },
- }
- queries := CollectPatrolTagQueries([]string{"化療 沐浴乳"}, nodes, websearch.ProviderBrave, libkg.PatrolTagInput{})
- if len(queries) == 0 || queries[0].ProductFitScore != 92 {
- t.Fatalf("expected graph fit 92, got %+v", queries)
- }
-}
-
-func TestResolveTagQueriesPrefersPatrolKeywords(t *testing.T) {
- nodes := []libkg.Node{
- {ID: "n1", Label: "ignored", SelectedForScan: true, DerivedTags: libkg.DerivedTags{Relevance: []string{"ignored"}}},
- }
- queries := ResolveTagQueries(nodes, []string{"手動 關鍵字"}, websearch.ProviderBrave, libkg.PatrolTagInput{})
- if len(queries) == 0 || queries[0].Tag != "手動 關鍵字" {
- t.Fatalf("expected patrol keyword query, got %+v", queries)
- }
-}
diff --git a/old/backend/internal/library/placement/post_relevance.go b/old/backend/internal/library/placement/post_relevance.go
deleted file mode 100644
index 3b717c8..0000000
--- a/old/backend/internal/library/placement/post_relevance.go
+++ /dev/null
@@ -1,534 +0,0 @@
-package placement
-
-import (
- "strings"
- "time"
- "unicode"
-
- libkg "haixun-backend/internal/library/knowledge"
-)
-
-const (
- minPostBodyProductFit = 50
- minPostBodyProductFitDiscover = 28
- minPostProductFitPatrol = 58
- minPostProductFitDiscover = 34
- minPostProductFitRelevant = 62
- minPostProductFitRelaxedFinal = 38
- minPostProductFitIngestFinal = 28
- minPostPillarAnchor = 1
- patrolMaxPostAgeDays = IdealMaxPostAgeDays
- relevanceMaxPostAgeDays = 14
-)
-
-// PostScanContext carries research map + product signals for post-level filtering.
-type PostScanContext struct {
- MatchTags []string
- ProductName string
- ProductFeatures string
- AudienceSummary string
- TargetAudience string
- Questions []string
- Pillars []string
- Exclusions []string
-}
-
-func PostScanContextFromPatrolInput(in libkg.PatrolTagInput, exclusions []string) PostScanContext {
- return PostScanContext{
- MatchTags: append([]string{}, in.MatchTags...),
- ProductName: strings.TrimSpace(in.ProductName),
- ProductFeatures: strings.TrimSpace(in.ProductFeatures),
- AudienceSummary: strings.TrimSpace(in.AudienceSummary),
- TargetAudience: strings.TrimSpace(in.TargetAudience),
- Questions: append([]string{}, in.Questions...),
- Pillars: append([]string{}, in.Pillars...),
- Exclusions: ExpandExclusionTokens(exclusions),
- }
-}
-
-func ScorePostProductFit(text, searchTag string, ctx PostScanContext) int {
- text = strings.ToLower(strings.TrimSpace(text))
- if text == "" {
- return 0
- }
- tagScore := ScoreProductForTag(searchTag, ctx.MatchTags)
- if tagScore == 0 && ctx.ProductName != "" {
- tagScore = ScoreProductForTag(searchTag, []string{ctx.ProductName})
- }
- postScore := ScoreProductForTag(text, ctx.MatchTags)
- if postScore == 0 && ctx.ProductName != "" {
- postScore = scoreTextAgainstPhrase(text, ctx.ProductName)
- }
- if ctx.ProductFeatures != "" {
- postScore = maxInt(postScore, scoreTextAgainstPhrase(text, ctx.ProductFeatures))
- }
- for _, pillar := range ctx.Pillars {
- postScore = maxInt(postScore, scoreTextAgainstPhrase(text, pillar)/2)
- }
- combined := postScore
- if tagScore > 0 && postScore > 0 {
- combined = (tagScore*2 + postScore*3) / 5
- } else if postScore > 0 {
- combined = postScore
- } else if tagScore > 0 {
- combined = tagScore / 2
- }
- if HasCategoryConflict(text, ctx) {
- return minInt(combined, 25)
- }
- if HasTangentialTopicMismatch(text, ctx) {
- return minInt(combined, 20)
- }
- intentScore := LocalIntentScore(text, ctx)
- if intentScore > combined {
- combined = (combined*2 + intentScore*3) / 5
- }
- return combined
-}
-
-// PassesDiscoverFilters is the crawl-stage gate: collect candidates from Search/Threads API.
-// Stricter ranking happens at finalize.
-func PassesDiscoverFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit, recencyDays int, ctx PostScanContext) bool {
- text = strings.TrimSpace(text)
- if text == "" {
- return false
- }
- if MatchesExclusion(text, ctx.Exclusions) {
- return false
- }
- if HasCategoryConflict(text, ctx) {
- return false
- }
- if HasTangentialTopicMismatch(text, ctx) {
- return false
- }
- if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
- return false
- }
- if !PassesPostAge(postedAt, dimension, recencyDays) {
- return false
- }
- bodyFit := ScorePostBodyProductFit(text, ctx)
- effectiveFit := bodyFit
- if tagFit > 0 && bodyFit > 0 {
- effectiveFit = (tagFit + bodyFit*2) / 3
- } else if tagFit > 0 && HasPlacementIntent(text) {
- effectiveFit = tagFit / 2
- }
- if bodyFit < minPostBodyProductFitDiscover {
- if !(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) {
- return false
- }
- }
- return effectiveFit >= minPostProductFitDiscover ||
- (HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal)
-}
-
-func PassesPostScanFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit int, ctx PostScanContext) bool {
- text = strings.TrimSpace(text)
- if text == "" {
- return false
- }
- if MatchesExclusion(text, ctx.Exclusions) {
- return false
- }
- if HasCategoryConflict(text, ctx) {
- return false
- }
- if HasTangentialTopicMismatch(text, ctx) {
- return false
- }
- if !HasPainPointDemand(text) {
- return false
- }
- if !PassesPostAge(postedAt, dimension, 0) {
- return false
- }
- bodyFit := ScorePostBodyProductFit(text, ctx)
- if bodyFit < minPostBodyProductFit {
- return false
- }
- if !ProductSolvesPostPain(text, ctx) {
- return false
- }
- effectiveFit := bodyFit
- if tagFit > 0 && bodyFit > 0 {
- effectiveFit = (tagFit + bodyFit*2) / 3
- }
- minFit := minPostProductFitPatrol
- if dimension == QueryRelevance {
- minFit = minPostProductFitRelevant
- }
- return effectiveFit >= minFit
-}
-
-// ScorePostBodyProductFit scores only the post body against the product offer (ignores search tag).
-func ScorePostBodyProductFit(text string, ctx PostScanContext) int {
- return ScorePostProductFit(text, "", ctx)
-}
-
-// ProductSolvesPostPain checks pain in the post aligns with what the product can solve.
-func ProductSolvesPostPain(text string, ctx PostScanContext) bool {
- if HasCategoryConflict(text, ctx) {
- return false
- }
- if HasTangentialTopicMismatch(text, ctx) {
- return false
- }
- if matchesTopicAnchor(text, ctx) {
- return true
- }
- bodyFit := ScorePostBodyProductFit(text, ctx)
- if bodyFit < minPostBodyProductFit {
- return false
- }
- for _, q := range ctx.Questions {
- if tokenHits(text, q) >= 2 && bodyFit >= minPostBodyProductFit {
- return true
- }
- }
- if ctx.ProductName != "" && scoreTextAgainstPhrase(text, ctx.ProductName) >= 55 {
- return true
- }
- for _, tag := range ctx.MatchTags {
- if scoreTextAgainstPhrase(text, tag) >= 55 {
- return true
- }
- }
- return LocalIntentScore(text, ctx) >= minPostBodyProductFit
-}
-
-// PostSolvedByProduct is the final flag for UI: demand + fit + no mismatch.
-func PostSolvedByProduct(text string, effectiveFit int, ctx PostScanContext) bool {
- return effectiveFit >= minPostProductFitPatrol &&
- HasPainPointDemand(text) &&
- ProductSolvesPostPain(text, ctx) &&
- !HasCategoryConflict(text, ctx) &&
- !HasTangentialTopicMismatch(text, ctx)
-}
-
-// PassesRelaxedFinalFilters keeps demand + basic fit when strict pass yields zero results.
-func PassesRelaxedFinalFilters(text string, effectiveFit int, ctx PostScanContext) bool {
- text = strings.TrimSpace(text)
- if text == "" {
- return false
- }
- if MatchesExclusion(text, ctx.Exclusions) {
- return false
- }
- if HasCategoryConflict(text, ctx) {
- return false
- }
- if HasTangentialTopicMismatch(text, ctx) {
- return false
- }
- if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
- return false
- }
- return effectiveFit >= minPostProductFitRelaxedFinal
-}
-
-// PassesIngestFallbackFilters keeps basic searchable posts when strict/relaxed gates yield zero.
-func PassesIngestFallbackFilters(text string, effectiveFit int, ctx PostScanContext) bool {
- text = strings.TrimSpace(text)
- if text == "" {
- return false
- }
- if MatchesExclusion(text, ctx.Exclusions) {
- return false
- }
- if HasCategoryConflict(text, ctx) {
- return false
- }
- if !HasPlacementIntent(text) && !HasPainPointDemand(text) {
- return false
- }
- return effectiveFit >= minPostProductFitIngestFinal
-}
-
-func matchesTopicAnchor(text string, ctx PostScanContext) bool {
- text = strings.ToLower(text)
- hits := 0
- for _, pillar := range ctx.Pillars {
- if tokenHits(text, pillar) > 0 {
- hits++
- }
- }
- for _, q := range ctx.Questions {
- if tokenHits(text, q) >= 2 {
- hits++
- }
- }
- if hits >= minPostPillarAnchor {
- return true
- }
- for _, tag := range ctx.MatchTags {
- if tokenHits(text, tag) > 0 {
- return true
- }
- }
- if ctx.ProductName != "" && tokenHits(text, ctx.ProductName) > 0 {
- return true
- }
- for _, phrase := range []string{ctx.AudienceSummary, ctx.TargetAudience} {
- if tokenHits(text, phrase) >= 2 {
- return true
- }
- }
- return false
-}
-
-func PassesPostAge(postedAt string, dimension QueryDimension, recencyDays int) bool {
- postedAt = strings.TrimSpace(postedAt)
- if postedAt == "" {
- // Web Search snippets often lack dates; do not drop at ingest.
- return true
- }
- maxDays := patrolMaxPostAgeDays
- switch dimension {
- case QueryRelevance:
- maxDays = relevanceMaxPostAgeDays
- case QueryRecency:
- if recencyDays > 0 {
- maxDays = recencyDays
- }
- }
- return IsPostWithinMaxAge(postedAt, maxDays, time.Now())
-}
-
-func IsPostWithinMaxAge(postedAt string, maxDays int, now time.Time) bool {
- if maxDays <= 0 {
- return true
- }
- ts, ok := ParsePostedAt(postedAt)
- if !ok {
- return true
- }
- if now.IsZero() {
- now = time.Now()
- }
- cutoff := now.AddDate(0, 0, -maxDays)
- return !ts.Before(cutoff)
-}
-
-func ParsePostedAt(raw string) (time.Time, bool) {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return time.Time{}, false
- }
- layouts := []string{
- time.RFC3339,
- "2006-01-02T15:04:05Z07:00",
- "2006-01-02 15:04:05",
- "2006-01-02",
- "2006/01/02",
- }
- for _, layout := range layouts {
- if ts, err := time.Parse(layout, raw); err == nil {
- return ts, true
- }
- }
- return time.Time{}, false
-}
-
-func ExpandExclusionTokens(exclusions []string) []string {
- seen := map[string]struct{}{}
- out := []string{}
- add := func(token string) {
- token = strings.TrimSpace(strings.ToLower(token))
- if len([]rune(token)) < 2 {
- return
- }
- if _, ok := seen[token]; ok {
- return
- }
- seen[token] = struct{}{}
- out = append(out, token)
- }
- for _, rule := range exclusions {
- rule = strings.TrimSpace(rule)
- if rule == "" {
- continue
- }
- add(rule)
- for _, token := range tokenizePhrase(rule) {
- add(token)
- }
- for _, synonym := range exclusionSynonyms(rule) {
- add(synonym)
- }
- }
- return out
-}
-
-func exclusionSynonyms(rule string) []string {
- rule = strings.ToLower(rule)
- out := []string{}
- for _, group := range exclusionCategoryGroups {
- if strings.Contains(rule, group.key) {
- out = append(out, group.tokens...)
- }
- }
- return out
-}
-
-var exclusionCategoryGroups = []struct {
- key string
- tokens []string
-}{
- {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房清潔", "油污"}},
- {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服清洗", "污漬"}},
- {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡"}},
- {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "頭皮", "髮品"}},
- {key: "寵物", tokens: []string{"寵物", "貓砂", "狗糧", "貓咪", "狗狗"}},
- {key: "廚房", tokens: []string{"廚房", "抽油煙機", "鍋具"}},
-}
-
-var productCategoryGroups = []struct {
- key string
- tokens []string
-}{
- {key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服", "污漬", "漂白"}},
- {key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房", "油污"}},
- {key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡", "沖澡"}},
- {key: "洗髮", tokens: []string{"洗髮", "洗髮精", "洗髮乳", "頭皮", "髮絲"}},
- {key: "清潔", tokens: []string{"清潔", "打掃", "去污"}},
-}
-
-func productCategories(ctx PostScanContext) []string {
- blob := strings.ToLower(ctx.ProductName + " " + ctx.ProductFeatures + " " + strings.Join(ctx.MatchTags, " "))
- found := []string{}
- for _, group := range productCategoryGroups {
- for _, token := range group.tokens {
- if strings.Contains(blob, token) {
- found = append(found, group.key)
- break
- }
- }
- }
- return found
-}
-
-func postCategories(text string) []string {
- text = strings.ToLower(text)
- found := []string{}
- for _, group := range productCategoryGroups {
- for _, token := range group.tokens {
- if strings.Contains(text, token) {
- found = append(found, group.key)
- break
- }
- }
- }
- return found
-}
-
-var genericProductCategories = map[string]struct{}{
- "清潔": {},
-}
-
-func HasCategoryConflict(text string, ctx PostScanContext) bool {
- productCats := productCategories(ctx)
- if len(productCats) == 0 {
- return false
- }
- postCats := postCategories(text)
- if len(postCats) == 0 {
- return false
- }
- productSet := map[string]struct{}{}
- for _, c := range productCats {
- productSet[c] = struct{}{}
- }
- for _, c := range postCats {
- if _, ok := genericProductCategories[c]; ok {
- continue
- }
- if _, ok := productSet[c]; !ok {
- return true
- }
- }
- return false
-}
-
-func scoreTextAgainstPhrase(text, phrase string) int {
- text = strings.ToLower(text)
- phrase = strings.TrimSpace(strings.ToLower(phrase))
- if text == "" || phrase == "" {
- return 0
- }
- if strings.Contains(text, phrase) {
- return 85
- }
- best := 0
- for _, token := range tokenizePhrase(phrase) {
- if len([]rune(token)) < 2 {
- continue
- }
- if strings.Contains(text, token) {
- best = maxInt(best, 55)
- }
- }
- return best
-}
-
-func tokenHits(text, phrase string) int {
- text = strings.ToLower(text)
- hits := 0
- for _, token := range tokenizePhrase(phrase) {
- if len([]rune(token)) < 2 {
- continue
- }
- if strings.Contains(text, token) {
- hits++
- }
- }
- return hits
-}
-
-func tokenizePhrase(phrase string) []string {
- phrase = strings.ToLower(strings.TrimSpace(phrase))
- if phrase == "" {
- return nil
- }
- repl := strings.NewReplacer(",", " ", "、", " ", "。", " ", ";", " ", "/", " ", "|", " ", "|", " ", "(", " ", ")", " ", "(", " ", ")", " ")
- phrase = repl.Replace(phrase)
- var tokens []string
- for _, part := range strings.Fields(phrase) {
- part = strings.Trim(part, `"'「」『』::`)
- if part != "" {
- tokens = append(tokens, part)
- }
- }
- if len(tokens) == 0 {
- return splitRunes(phrase)
- }
- return tokens
-}
-
-func splitRunes(s string) []string {
- var out []string
- var buf []rune
- flush := func() {
- if len(buf) >= 2 {
- out = append(out, string(buf))
- }
- buf = buf[:0]
- }
- for _, r := range s {
- if unicode.Is(unicode.Han, r) {
- buf = append(buf, r)
- continue
- }
- flush()
- }
- flush()
- return out
-}
-
-func minInt(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/old/backend/internal/library/placement/post_relevance_test.go b/old/backend/internal/library/placement/post_relevance_test.go
deleted file mode 100644
index 7609dc9..0000000
--- a/old/backend/internal/library/placement/post_relevance_test.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package placement
-
-import (
- "testing"
- "time"
-
- libkg "haixun-backend/internal/library/knowledge"
-)
-
-func TestHasCategoryConflictLaundryVsDish(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精", "衣物清潔"},
- }, nil)
- text := "請問大家洗碗機清潔劑有推薦嗎?碗盤油污洗不掉"
- if !HasCategoryConflict(text, ctx) {
- t.Fatal("expected dishwasher post to conflict with laundry product")
- }
- if PassesPostScanFilters(text, "洗衣精 推薦", "", QueryRecency, 80, ctx) {
- t.Fatal("expected conflicting post to be filtered out")
- }
-}
-
-func TestPassesPostScanFiltersAcceptsMatchingLaundry(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精", "衣物"},
- Pillars: []string{"衣物清潔", "敏感皮膚洗衣"},
- }, []string{"洗碗精等其他品類"})
- text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
- if !PassesPostScanFilters(text, "洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 70, ctx) {
- t.Fatal("expected matching laundry post to pass")
- }
-}
-
-func TestPassesPostAgeAllowsMissingDateOnRelevance(t *testing.T) {
- if !PassesPostAge("", QueryRelevance, 0) {
- t.Fatal("expected missing posted_at to pass age gate at ingest")
- }
-}
-
-func TestPassesDiscoverFiltersAllowsWebSnippetWithoutDate(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精", "衣物"},
- }, nil)
- text := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
- if !PassesDiscoverFilters(text, "洗衣精 推薦", "", QueryRelevance, 70, 0, ctx) {
- t.Fatal("expected web snippet without date to pass discover filters")
- }
-}
-
-func TestPassesPostAgeRejectsOldRelevancePost(t *testing.T) {
- old := time.Now().AddDate(0, 0, -20).Format("2006-01-02")
- if PassesPostAge(old, QueryRelevance, 0) {
- t.Fatal("expected old relevance post to fail age gate")
- }
-}
-
-func TestHasPainPointDemandRejectsCasualChat(t *testing.T) {
- if HasPainPointDemand("今天天氣真好,晒照給大家看") {
- t.Fatal("expected casual chat without demand to fail")
- }
-}
-
-func TestHasPainPointDemandAcceptsHelpSeeking(t *testing.T) {
- text := "最近皮膚一直癢,沐浴乳有推薦嗎?"
- if !HasPainPointDemand(text) {
- t.Fatal("expected help-seeking post with pain signal to pass")
- }
-}
-
-func TestPostSolvedByProductRejectsCategoryMismatch(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精"},
- }, nil)
- text := "洗碗精有推薦嗎?碗盤油污洗不掉"
- if PostSolvedByProduct(text, 80, ctx) {
- t.Fatal("expected dish post not to be solved by laundry product")
- }
-}
-
-func TestHasTangentialTopicMismatchRejectsWashingMachine(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏、適合敏感肌與化療族群",
- MatchTags: []string{"無香", "抗敏", "洗衣精"},
- Pillars: []string{"化療後衣物清潔", "無香洗衣"},
- }, nil)
- text := "請問大家有推薦的洗衣機嗎?想買洗脫烘"
- if !HasTangentialTopicMismatch(text, ctx) {
- t.Fatal("expected washing machine post to be tangential mismatch for hypoallergenic detergent")
- }
- if PassesPostScanFilters(text, "無香 洗衣精 推薦", time.Now().Format("2006-01-02"), QueryRecency, 80, ctx) {
- t.Fatal("expected washing machine post to be filtered out")
- }
-}
-
-func TestHasTangentialTopicMismatchAcceptsDetergentPain(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏",
- MatchTags: []string{"無香", "洗衣精"},
- }, nil)
- text := "化療後對香味很敏感,有無香洗衣精推薦嗎?"
- if HasTangentialTopicMismatch(text, ctx) {
- t.Fatal("expected detergent pain post to pass tangential check")
- }
-}
-
-func TestHasTangentialTopicMismatchAcceptsMachineWithDetergentNeed(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "抗敏無香洗衣精",
- ProductFeatures: "完全無香、抗敏",
- MatchTags: []string{"無香", "洗衣精"},
- }, nil)
- text := "買了洗衣機,但想找不會讓皮膚癢的無香洗衣精"
- if HasTangentialTopicMismatch(text, ctx) {
- t.Fatal("expected combined machine + detergent pain post to pass")
- }
-}
-
-func TestPostSolvedByProductAcceptsAlignedDemand(t *testing.T) {
- ctx := PostScanContextFromPatrolInput(libkg.PatrolTagInput{
- ProductName: "天然洗衣精",
- MatchTags: []string{"洗衣精", "衣物"},
- Pillars: []string{"衣物清潔"},
- }, nil)
- text := "衣服污漬洗不掉,洗衣精有推薦嗎?"
- if !PostSolvedByProduct(text, 70, ctx) {
- t.Fatal("expected aligned laundry demand to be solved by product")
- }
-}
-
-func TestExpandExclusionTokensIncludesDishSynonyms(t *testing.T) {
- tokens := ExpandExclusionTokens([]string{"洗碗精等其他品類"})
- found := false
- for _, token := range tokens {
- if token == "洗碗機" || token == "洗碗精" {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("expected dish synonyms, got %v", tokens)
- }
-}
diff --git a/old/backend/internal/library/placement/product_catalog.go b/old/backend/internal/library/placement/product_catalog.go
deleted file mode 100644
index 32ef56b..0000000
--- a/old/backend/internal/library/placement/product_catalog.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package placement
-
-import (
- "strings"
-
- brandentity "haixun-backend/internal/model/brand/domain/entity"
-)
-
-func BuildMergedProductContext(brandName, productContext, productLabel string) string {
- fields := ParseProductContext(productContext)
- if name := strings.TrimSpace(brandName); name != "" {
- fields.Brand = name
- }
- if label := strings.TrimSpace(productLabel); label != "" && fields.Product == "" {
- fields.Product = label
- }
- return SerializeProductContext(fields)
-}
-
-func RecommendProduct(products []brandentity.Product, searchTag, defaultProductID string) *brandentity.Product {
- if len(products) == 0 {
- return nil
- }
- var defaultProduct *brandentity.Product
- if id := strings.TrimSpace(defaultProductID); id != "" {
- for i := range products {
- if products[i].ID == id {
- defaultProduct = &products[i]
- break
- }
- }
- }
- tag := strings.TrimSpace(searchTag)
- if tag == "" {
- if defaultProduct != nil {
- return defaultProduct
- }
- return &products[0]
- }
-
- bestScore := -1
- var best *brandentity.Product
- for i := range products {
- score := ScoreProductForTag(tag, products[i].MatchTags)
- if score > bestScore {
- bestScore = score
- best = &products[i]
- }
- }
- if best != nil && bestScore > 0 {
- return best
- }
- if defaultProduct != nil {
- return defaultProduct
- }
- return &products[0]
-}
-
-func ResolveBrandProductContext(brand brandentity.Brand, productID, searchTag string) string {
- if len(brand.Products) > 0 {
- picked := RecommendProduct(brand.Products, searchTag, productID)
- if picked != nil {
- merged := BuildMergedProductContext(brand.DisplayName, picked.ProductContext, picked.Label)
- return ApplyPlacementURL(merged, picked.PlacementURL)
- }
- }
- if formatted := ProductBriefFromContext(brand.ProductContext); formatted != "" {
- return formatted
- }
- if brief := strings.TrimSpace(brand.ProductBrief); brief != "" {
- return brief
- }
- return ""
-}
-
-func FindProduct(brand brandentity.Brand, productID string) *brandentity.Product {
- id := strings.TrimSpace(productID)
- if id == "" {
- return nil
- }
- for i := range brand.Products {
- if brand.Products[i].ID == id {
- return &brand.Products[i]
- }
- }
- return nil
-}
diff --git a/old/backend/internal/library/placement/product_context.go b/old/backend/internal/library/placement/product_context.go
deleted file mode 100644
index a8c6b2f..0000000
--- a/old/backend/internal/library/placement/product_context.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package placement
-
-import (
- "encoding/json"
- "strings"
-)
-
-type CtaType string
-
-const (
- CtaNone CtaType = "none"
- CtaLink CtaType = "link"
- CtaDM CtaType = "dm"
- CtaFollow CtaType = "follow"
-)
-
-type ProductContextFields struct {
- Brand string `json:"brand"`
- Product string `json:"product"`
- Features string `json:"features"`
- PlacementTone string `json:"placementTone,omitempty"`
- CtaType CtaType `json:"ctaType,omitempty"`
- CtaUrl string `json:"ctaUrl,omitempty"`
-}
-
-func ParseProductContext(raw string) ProductContextFields {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return ProductContextFields{CtaType: CtaNone}
- }
- var parsed ProductContextFields
- if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
- return ProductContextFields{CtaType: CtaNone, Features: raw}
- }
- parsed.Brand = strings.TrimSpace(parsed.Brand)
- parsed.Product = strings.TrimSpace(parsed.Product)
- parsed.Features = strings.TrimSpace(parsed.Features)
- parsed.PlacementTone = strings.TrimSpace(parsed.PlacementTone)
- parsed.CtaUrl = strings.TrimSpace(parsed.CtaUrl)
- if parsed.CtaType == "" {
- parsed.CtaType = CtaNone
- }
- return parsed
-}
-
-func SerializeProductContext(fields ProductContextFields) string {
- brand := strings.TrimSpace(fields.Brand)
- product := strings.TrimSpace(fields.Product)
- features := strings.TrimSpace(fields.Features)
- tone := strings.TrimSpace(fields.PlacementTone)
- ctaType := fields.CtaType
- if ctaType == "" {
- ctaType = CtaNone
- }
- ctaUrl := strings.TrimSpace(fields.CtaUrl)
- if brand == "" && product == "" && features == "" && tone == "" && ctaType == CtaNone && ctaUrl == "" {
- return ""
- }
- payload, _ := json.Marshal(ProductContextFields{
- Brand: brand,
- Product: product,
- Features: features,
- PlacementTone: tone,
- CtaType: ctaType,
- CtaUrl: ctaUrl,
- })
- return string(payload)
-}
-
-func FormatProductContextForPrompt(raw string) string {
- fields := ParseProductContext(raw)
- lines := []string{}
- if fields.Brand != "" {
- lines = append(lines, "品牌:"+fields.Brand)
- }
- if fields.Product != "" {
- lines = append(lines, "產品:"+fields.Product)
- }
- if fields.Features != "" {
- lines = append(lines, "特色/能幫上忙的地方:"+fields.Features)
- }
- if fields.PlacementTone != "" {
- lines = append(lines, "置入語氣偏好:"+fields.PlacementTone)
- }
- if fields.CtaType == CtaLink && fields.CtaUrl != "" {
- lines = append(lines, "留言 CTA 連結:"+fields.CtaUrl)
- } else if fields.CtaType == CtaDM {
- lines = append(lines, "留言 CTA:引導私訊")
- } else if fields.CtaType == CtaFollow {
- lines = append(lines, "留言 CTA:引導追蹤")
- }
- if len(lines) == 0 {
- return ""
- }
- return strings.Join(lines, "\n")
-}
-
-func ApplyPlacementURL(productContext, placementURL string) string {
- placementURL = strings.TrimSpace(placementURL)
- if placementURL == "" {
- return productContext
- }
- fields := ParseProductContext(productContext)
- if fields.CtaType == CtaLink && strings.TrimSpace(fields.CtaUrl) != "" {
- return productContext
- }
- fields.CtaType = CtaLink
- fields.CtaUrl = placementURL
- return SerializeProductContext(fields)
-}
-
-func ProductBriefFromContext(raw string) string {
- formatted := FormatProductContextForPrompt(raw)
- if formatted != "" {
- return formatted
- }
- return strings.TrimSpace(raw)
-}
-
-func HasProductContext(raw string) bool {
- fields := ParseProductContext(raw)
- return fields.Brand != "" || fields.Product != "" || fields.Features != ""
-}
diff --git a/old/backend/internal/library/placement/product_context_placement_url_test.go b/old/backend/internal/library/placement/product_context_placement_url_test.go
deleted file mode 100644
index 37b91ea..0000000
--- a/old/backend/internal/library/placement/product_context_placement_url_test.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package placement
-
-import (
- "strings"
- "testing"
-)
-
-func TestApplyPlacementURL(t *testing.T) {
- raw := `{"brand":"ecostore","product":"沐浴露","features":"無香抗敏"}`
- got := ApplyPlacementURL(raw, "https://shop.example.com/body-wash")
- formatted := ProductBriefFromContext(got)
- if !strings.Contains(formatted, "留言 CTA 連結") || !strings.Contains(formatted, "https://shop.example.com/body-wash") {
- t.Fatalf("formatted brief = %q", formatted)
- }
-
- existing := SerializeProductContext(ProductContextFields{
- Brand: "ecostore",
- Product: "沐浴露",
- CtaType: CtaLink,
- CtaUrl: "https://existing.example.com",
- })
- if changed := ApplyPlacementURL(existing, "https://new.example.com"); changed != existing {
- t.Fatalf("should not override existing cta url")
- }
-}
diff --git a/old/backend/internal/library/placement/product_match.go b/old/backend/internal/library/placement/product_match.go
deleted file mode 100644
index 75afa19..0000000
--- a/old/backend/internal/library/placement/product_match.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package placement
-
-import (
- "strings"
-
- brandentity "haixun-backend/internal/model/brand/domain/entity"
-)
-
-func ScoreProductForTag(searchTag string, matchTags []string) int {
- tag := strings.TrimSpace(strings.ToLower(searchTag))
- if tag == "" || len(matchTags) == 0 {
- return 0
- }
- best := 0
- for _, raw := range matchTags {
- candidate := strings.TrimSpace(strings.ToLower(raw))
- if candidate == "" {
- continue
- }
- switch {
- case tag == candidate:
- best = maxInt(best, 100)
- case strings.Contains(tag, candidate) || strings.Contains(candidate, tag):
- best = maxInt(best, 70)
- default:
- tagWords := strings.Fields(tag)
- candWords := strings.Fields(candidate)
- overlap := 0
- for _, w := range tagWords {
- for _, c := range candWords {
- if strings.Contains(c, w) || strings.Contains(w, c) {
- overlap++
- break
- }
- }
- }
- if overlap > 0 {
- best = maxInt(best, 40+overlap*10)
- }
- }
- }
- return best
-}
-
-// ScoreProductForPost ranks a SKU against the search tag and post body.
-func ScoreProductForPost(productLabel string, matchTags []string, searchTag, postText string) int {
- tagScore := ScoreProductForTag(searchTag, matchTags)
- if tagScore == 0 && strings.TrimSpace(productLabel) != "" {
- tagScore = ScoreProductForTag(searchTag, []string{productLabel})
- }
- postScore := ScoreProductForTag(postText, matchTags)
- if postScore == 0 && strings.TrimSpace(productLabel) != "" {
- postScore = ScoreProductForTag(postText, []string{productLabel})
- }
- for _, token := range categoryTokens(productLabel, matchTags) {
- if strings.Contains(strings.ToLower(postText), token) {
- postScore = maxInt(postScore, 78)
- }
- if strings.Contains(strings.ToLower(searchTag), token) {
- tagScore = maxInt(tagScore, 82)
- }
- }
- switch {
- case tagScore > 0 && postScore > 0:
- return (tagScore*2 + postScore*3) / 5
- case postScore > 0:
- return postScore
- default:
- return tagScore
- }
-}
-
-// RecommendProductForPost picks the best SKU for a single post.
-func RecommendProductForPost(products []brandentity.Product, searchTag, postText, defaultProductID string) *brandentity.Product {
- if len(products) == 0 {
- return nil
- }
- bestScore := 0
- var best *brandentity.Product
- for i := range products {
- score := ScoreProductForPost(products[i].Label, products[i].MatchTags, searchTag, postText)
- if score > bestScore {
- bestScore = score
- best = &products[i]
- }
- }
- if best != nil && bestScore >= 55 {
- return best
- }
- return RecommendProduct(products, searchTag, defaultProductID)
-}
-
-func categoryTokens(productLabel string, matchTags []string) []string {
- blob := strings.ToLower(strings.TrimSpace(productLabel + " " + strings.Join(matchTags, " ")))
- tokens := []string{
- "洗衣精", "洗衣粉", "洗衣劑", "洗衣液",
- "沐浴乳", "沐浴露",
- "洗髮精", "洗髮乳",
- "洗碗精", "洗碗劑",
- }
- out := make([]string, 0, 4)
- for _, token := range tokens {
- if strings.Contains(blob, token) {
- out = append(out, token)
- }
- }
- return out
-}
-
-func maxInt(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
diff --git a/old/backend/internal/library/placement/product_match_test.go b/old/backend/internal/library/placement/product_match_test.go
deleted file mode 100644
index c0b9998..0000000
--- a/old/backend/internal/library/placement/product_match_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package placement
-
-import (
- "testing"
-
- brandentity "haixun-backend/internal/model/brand/domain/entity"
-)
-
-func TestRecommendProductForPostPicksLaundryOverDefaultBodyWash(t *testing.T) {
- products := []brandentity.Product{
- {ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}},
- {ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}},
- }
- post := "最近衣服洗不乾淨,洗衣精有推薦嗎?污漬一直洗不掉"
- got := RecommendProductForPost(products, "洗衣精 推薦", post, "body")
- if got == nil || got.ID != "laundry" {
- id := ""
- if got != nil {
- id = got.ID
- }
- t.Fatalf("expected laundry product, got %q", id)
- }
-}
-
-func TestRecommendProductForPostPicksBodyWashForShowerPost(t *testing.T) {
- products := []brandentity.Product{
- {ID: "body", Label: "無香沐浴乳", MatchTags: []string{"沐浴乳", "無香", "敏感"}},
- {ID: "laundry", Label: "天然洗衣精", MatchTags: []string{"洗衣精", "衣物", "污漬"}},
- }
- post := "化療後皮膚很乾,沐浴乳有推薦嗎?想要無香味"
- got := RecommendProductForPost(products, "沐浴乳 推薦", post, "laundry")
- if got == nil || got.ID != "body" {
- id := ""
- if got != nil {
- id = got.ID
- }
- t.Fatalf("expected body wash product, got %q", id)
- }
-}
diff --git a/old/backend/internal/library/placement/query_build.go b/old/backend/internal/library/placement/query_build.go
deleted file mode 100644
index b776973..0000000
--- a/old/backend/internal/library/placement/query_build.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package placement
-
-import (
- "fmt"
- "strings"
- "time"
-
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/websearch"
-)
-
-type QueryDimension string
-
-const (
- QueryRelevance QueryDimension = "relevance"
- QueryRecency QueryDimension = "recency"
-)
-
-type TagQuery struct {
- Tag string
- Query string
- Dimension QueryDimension
- GraphNodeID string
- ProductFitScore int
- RecencyDays int // 0 = no after filter; 7 or 30 for recency track
-}
-
-func BuildRelevanceQuery(provider websearch.Provider, tag string) string {
- if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
- full := strings.TrimSpace(libkg.FullPatrolWebSearchTag(tag))
- if full == "" {
- return ""
- }
- return fmt.Sprintf("Threads 貼文 繁體中文 %s", full)
- }
- core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
- if core == "" {
- core = strings.TrimSpace(tag)
- }
- if core == "" {
- return ""
- }
- // Unquoted site search matches more real Threads posts than exact long phrases.
- return `site:threads.net ` + core + ` 推薦`
-}
-
-func BuildRecencyQuery(provider websearch.Provider, tag string, maxAgeDays int) string {
- core := strings.TrimSpace(libkg.ShortPatrolSearchCore(tag))
- if core == "" {
- core = strings.TrimSpace(tag)
- }
- if core == "" {
- return ""
- }
- if websearch.ParseProvider(string(provider)) == websearch.ProviderExa {
- return fmt.Sprintf("Threads 近期貼文 繁體中文 %s 請問", core)
- }
- after := FormatAfterDate(maxAgeDays, timeNow())
- return `site:threads.net "` + core + `" 請問 after:` + after
-}
-
-func PublishedAfterForRecency(provider websearch.Provider, maxAgeDays int) string {
- if maxAgeDays <= 0 || websearch.ParseProvider(string(provider)) != websearch.ProviderExa {
- return ""
- }
- return FormatPublishedAfterISO(maxAgeDays, timeNow())
-}
-
-var timeNow = func() time.Time { return time.Now() }
-
-// SetTimeNowForTest overrides time source in tests.
-func SetTimeNowForTest(fn func() time.Time) {
- timeNow = fn
-}
diff --git a/old/backend/internal/library/placement/query_build_test.go b/old/backend/internal/library/placement/query_build_test.go
deleted file mode 100644
index 3ff0a7f..0000000
--- a/old/backend/internal/library/placement/query_build_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package placement
-
-import (
- "strings"
- "testing"
-
- "haixun-backend/internal/library/websearch"
-)
-
-func TestBuildRelevanceQueryUsesBroadSiteSearch(t *testing.T) {
- q := BuildRelevanceQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦")
- if !strings.HasPrefix(q, "site:threads.net ") {
- t.Fatalf("unexpected query %q", q)
- }
- if strings.Contains(q, `"`) {
- t.Fatalf("expected unquoted broad query, got %q", q)
- }
- if !strings.Contains(q, "推薦") {
- t.Fatalf("expected intent marker in query, got %q", q)
- }
-}
-
-func TestBuildRecencyQueryUsesShortCore(t *testing.T) {
- q := BuildRecencyQuery(websearch.ProviderBrave, "化療 無香 洗衣精 推薦 請問", 7)
- if !strings.Contains(q, "請問 after:") {
- t.Fatalf("expected recency date filter, got %q", q)
- }
- quoted := strings.Trim(q, `site:threads.net `)
- if strings.Contains(quoted, "推薦") {
- t.Fatalf("expected short quoted core without 推薦, got %q", q)
- }
-}
diff --git a/old/backend/internal/library/placement/recency.go b/old/backend/internal/library/placement/recency.go
deleted file mode 100644
index fe273e9..0000000
--- a/old/backend/internal/library/placement/recency.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package placement
-
-import "time"
-
-const (
- MaxPostAgeDays = 30
- IdealMaxPostAgeDays = 7
- WebSearchMaxAgeDays = 14
-
- // MinRecencyCandidatesPerTag triggers wider recency windows when a keyword finds fewer posts.
- MinRecencyCandidatesPerTag = 1
- // MaxRecencyCascadeQueries caps extra Search API calls per patrol run.
- MaxRecencyCascadeQueries = 10
-)
-
-// RecencyCascadeDays is the patrol recency ladder: nearest window first, then shift older.
-func RecencyCascadeDays() []int {
- return []int{IdealMaxPostAgeDays, WebSearchMaxAgeDays, MaxPostAgeDays}
-}
-
-func FormatAfterDate(maxAgeDays int, now time.Time) string {
- if now.IsZero() {
- now = time.Now()
- }
- date := now.AddDate(0, 0, -maxAgeDays).UTC()
- return date.Format("2006-01-02")
-}
-
-func FormatPublishedAfterISO(maxAgeDays int, now time.Time) string {
- if now.IsZero() {
- now = time.Now()
- }
- date := now.AddDate(0, 0, -maxAgeDays).UTC()
- return date.Format("2006-01-02T15:04:05.000Z")
-}
diff --git a/old/backend/internal/library/placement/recency_cascade.go b/old/backend/internal/library/placement/recency_cascade.go
deleted file mode 100644
index 746283c..0000000
--- a/old/backend/internal/library/placement/recency_cascade.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package placement
-
-import (
- "haixun-backend/internal/library/websearch"
-)
-
-type tagCascadeState struct {
- Tag string
- GraphNodeID string
- ProductFitScore int
- RanWindows map[int]bool
-}
-
-func buildTagCascadeStates(queries []TagQuery) []*tagCascadeState {
- states := map[string]*tagCascadeState{}
- for _, q := range queries {
- key := patrolTagMatchKey(q.Tag)
- if key == "" {
- continue
- }
- st, ok := states[key]
- if !ok {
- st = &tagCascadeState{
- Tag: q.Tag,
- RanWindows: map[int]bool{},
- }
- states[key] = st
- }
- if q.GraphNodeID != "" {
- st.GraphNodeID = q.GraphNodeID
- }
- if q.ProductFitScore > st.ProductFitScore {
- st.ProductFitScore = q.ProductFitScore
- }
- if q.Dimension == QueryRecency && q.RecencyDays > 0 {
- st.RanWindows[q.RecencyDays] = true
- }
- }
- out := make([]*tagCascadeState, 0, len(states))
- for _, st := range states {
- out = append(out, st)
- }
- return out
-}
-
-func countCandidatesForTag(merged map[string]*ScanCandidate, tag string) int {
- key := patrolTagMatchKey(tag)
- if key == "" {
- return 0
- }
- count := 0
- for _, item := range merged {
- if item == nil {
- continue
- }
- if patrolTagMatchKey(item.SearchTag) == key {
- count++
- }
- }
- return count
-}
-
-func nextRecencyCascadeWindow(ran map[int]bool) int {
- for _, days := range RecencyCascadeDays() {
- if ran[days] {
- continue
- }
- return days
- }
- return 0
-}
-
-func buildRecencyCascadeQuery(st *tagCascadeState, days int, provider websearch.Provider) (TagQuery, bool) {
- if st == nil || days <= 0 {
- return TagQuery{}, false
- }
- query := BuildRecencyQuery(provider, st.Tag, days)
- if query == "" {
- return TagQuery{}, false
- }
- return TagQuery{
- Tag: st.Tag,
- Query: query,
- Dimension: QueryRecency,
- GraphNodeID: st.GraphNodeID,
- ProductFitScore: st.ProductFitScore,
- RecencyDays: days,
- }, true
-}
diff --git a/old/backend/internal/library/placement/recency_cascade_test.go b/old/backend/internal/library/placement/recency_cascade_test.go
deleted file mode 100644
index 7c9082b..0000000
--- a/old/backend/internal/library/placement/recency_cascade_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package placement
-
-import (
- "testing"
-
- "haixun-backend/internal/library/websearch"
-)
-
-func TestNextRecencyCascadeWindowSkipsRanWindows(t *testing.T) {
- ran := map[int]bool{7: true}
- if got := nextRecencyCascadeWindow(ran); got != 14 {
- t.Fatalf("expected 14, got %d", got)
- }
- ran[14] = true
- if got := nextRecencyCascadeWindow(ran); got != 30 {
- t.Fatalf("expected 30, got %d", got)
- }
- ran[30] = true
- if got := nextRecencyCascadeWindow(ran); got != 0 {
- t.Fatalf("expected 0, got %d", got)
- }
-}
-
-func TestPassesPostAgeUsesRecencyWindow(t *testing.T) {
- old := timeNow().AddDate(0, 0, -10).Format("2006-01-02")
- if PassesPostAge(old, QueryRecency, 7) {
- t.Fatal("expected 10-day-old post to fail 7-day recency window")
- }
- if !PassesPostAge(old, QueryRecency, 14) {
- t.Fatal("expected 10-day-old post to pass 14-day recency window")
- }
-}
-
-func TestBuildRecencyCascadeQuery(t *testing.T) {
- st := &tagCascadeState{Tag: "洗衣精 推薦", ProductFitScore: 55}
- tq, ok := buildRecencyCascadeQuery(st, 14, websearch.ProviderBrave)
- if !ok || tq.RecencyDays != 14 || tq.Dimension != QueryRecency {
- t.Fatalf("unexpected cascade query: %+v ok=%v", tq, ok)
- }
-}
diff --git a/old/backend/internal/library/placement/research_map.go b/old/backend/internal/library/placement/research_map.go
deleted file mode 100644
index b584ab8..0000000
--- a/old/backend/internal/library/placement/research_map.go
+++ /dev/null
@@ -1,316 +0,0 @@
-package placement
-
-import (
- "fmt"
- "regexp"
- "strings"
- "unicode/utf8"
-
- "haixun-backend/internal/library/llmjson"
-)
-
-type ResearchMap struct {
- AudienceSummary string `json:"audienceSummary"`
- ContentGoal string `json:"contentGoal"`
- Questions []string `json:"questions"`
- Pillars []string `json:"pillars"`
- Exclusions []string `json:"exclusions"`
- PatrolKeywords []string `json:"patrolKeywords"`
-}
-
-type ResearchMapInput struct {
- BrandDisplayName string
- TopicName string
- SeedQuery string
- Brief string
- Goals string
- TargetAudience string
- ProductContext string
- ProductBrief string
- ProductLabel string
-}
-
-const (
- minResearchQuestions = 8
- minResearchPillars = 6
- minResearchExclusions = 8
- minAudienceRunes = 80
- minContentGoalRunes = 50
-)
-
-var researchMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-func BuildResearchMapAnalysisSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。請先閱讀使用者提供的**品牌、置入產品、主題目標**等輸入,完成結構化分析。
-
-## 任務
-- 整合所有輸入,想清楚受眾是誰、他們在煩什麼、產品能解決什麼、什麼貼文值得留言置入
-- **本步驟只輸出分析文字,不要輸出 JSON**
-
-## 請依序回答(每段 2~4 句,繁體中文,具體可執行)
-1. **品牌與產品**:這個品牌/產品能解決什麼具體困擾?關鍵賣點是什麼?
-2. **受眾輪廓**:誰、在什麼生活/治療情境、會因什麼症狀發文求助?
-3. **求助句型方向**:Threads 上可能出現哪些求助句(列 6~10 個方向,尚未定稿)
-4. **內容方向與排除**:適合找文的支柱主題、必須排除的貼文類型(排除項只寫內容類型,不寫發文時間)
-5. **置入策略**:contentGoal 應點名的完整產品名稱、近期發文窗口、留言置入時機
-
-分析必須緊扣輸入資料,不可套用與品牌/產品/主題無關的通用範例。`)
-}
-
-func BuildResearchMapAnalysisUserPrompt(in ResearchMapInput) string {
- var b strings.Builder
- b.WriteString("以下為本次置入主題的完整輸入,請先分析:\n\n")
- PlacementTopicContext{
- BrandDisplayName: in.BrandDisplayName,
- TopicName: in.TopicName,
- SeedQuery: in.SeedQuery,
- Brief: in.Brief,
- Goals: in.Goals,
- TargetAudience: in.TargetAudience,
- ProductContext: in.ProductContext,
- ProductBrief: in.ProductBrief,
- ProductLabel: in.ProductLabel,
- }.WritePromptBlock(&b)
- return b.String()
-}
-
-func BuildResearchMapFinalizeUserPrompt(in ResearchMapInput, analysis string) string {
- var b strings.Builder
- b.WriteString("【輸入資料】\n")
- PlacementTopicContext{
- BrandDisplayName: in.BrandDisplayName,
- TopicName: in.TopicName,
- SeedQuery: in.SeedQuery,
- Brief: in.Brief,
- Goals: in.Goals,
- TargetAudience: in.TargetAudience,
- ProductContext: in.ProductContext,
- ProductBrief: in.ProductBrief,
- ProductLabel: in.ProductLabel,
- }.WritePromptBlock(&b)
- analysis = strings.TrimSpace(analysis)
- if analysis != "" {
- b.WriteString("\n【前置分析】\n")
- b.WriteString(analysis)
- b.WriteString("\n")
- }
- b.WriteString(`
----
-
-請依【前置分析】與【輸入資料】產出**完整**研究地圖 JSON(不可精簡),密度請對齊系統 prompt 中的優秀範例:
-1. 每個欄位必須反映本次品牌、產品與使用者輸入,不可照搬範例的主題用字
-2. audienceSummary 寫清楚「誰、治療/生活情境、具體困擾、在找什麼產品特徵」
-3. contentGoal 要寫入【產品詳情】的完整產品名稱、近期發文、留言置入時機
-4. questions 至少 8 句、pillars 至少 6 句、exclusions 至少 8 句(exclusions 只寫貼文類型,**不要寫時間/近期/幾天內**)
-5. questions 是「人會發文的求助句」;patrolKeywords 是「人會打進 Threads 搜尋框的短句」,兩者不可混在一起
-6. patrolKeywords 至少 8 組、最多 10 組,每組 6~16 字,從 questions 提煉搜尋短句,必須可直接拿去搜尋`)
- return b.String()
-}
-
-func BuildResearchMapSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads(脆)產品置入研究顧問。目標是幫品牌找到「近期發文、作者有需求、現在留言還來得及自然推薦產品」的貼文——不是找爆款來模仿發文。
-
-## 工作流程
-- 你會收到【輸入資料】(品牌、產品、主題目標等)與【前置分析】
-- 請先理解分析與輸入的對應關係,再產出研究地圖 JSON
-- 產出必須緊扣本次品牌與產品,不可套用與輸入無關的通用內容
-
-## 產出密度(重要)
-- 這份研究地圖要給真人閱讀與執行,**寧可詳盡也不要過度精簡**
-- 每個欄位都要寫滿規格下限;不可用單一關鍵字、標籤語或一句話敷衍
-- 只回傳一個 JSON 物件,不要 markdown 說明
-
-## 核心原則
-1. questions 與 pillars 會直接拿去 Threads 找貼文——這兩項是最重要的產出
-2. 置入時間窗口最重要:優先「近期」求助帖,作者有困擾、留言區還能自然推薦
-3. 海巡要能盡量找到可置入貼文,但寧可少給也不要跑題
-4. 複合主題不可拆成過寬單字(寵物洗毛精 ≠ 只搜「寵物」「狗狗」)
-5. 全部繁體中文,貼近台灣 Threads 使用者口語
-
-## audienceSummary(3~5 句,至少 80 字)
-- 具體描述:誰、生活情境、正在煩什麼、為什麼會發文求助、跟產品有什麼關係
-- 不要寫「注重保養的消費者」這類空泛句
-
-## contentGoal(2~3 句,至少 50 字)
-- 明確寫:找到「近期發文(理想 3 天內)」、作者本身有產品可解決的需求、留言區還能自然推薦的貼文
-- **必須寫入【產品置入】中的產品名稱**(含品類與關鍵賣點,如「抗敏無香沐浴露」),不可用「目標產品」等泛稱代替
-- 強調「現在就能留言置入還來得及且不突兀」,不是模仿發文或內容企劃
-
-## questions(至少 8 個,每句 8~24 字)
-- 像真人會在 Threads 打的求助句,帶治療階段、困擾、求推薦、請益、經驗分享
-- 要涵蓋不同角度(症狀、能不能用、品牌推薦、挑選經驗、康復後換品)
-- 差:「癌症保養」「沐浴乳」這種單一關鍵字
-
-## pillars(至少 6 個,每句 6~22 字)
-- 允許的內容方向,用來找貼文並過濾結果;要比 questions 更偏主題詞組
-- 差:「保養」「健康」這種過寬詞
-
-## exclusions(至少 8 個,每句 8~28 字)
-- 觸及即排除的**貼文類型/內容方向**;要寫清楚「為什麼不能置入」(純晒照、業配、無求助、跑題癌別、錯品類、已滿意他牌、非病友視角等)
-- **禁止寫時間相關條件**:不要碰不是篩發文時間用的。不可出現「過舊貼文」「非近期發文」「3 天前」「一週以上」「發文太久」等——置入時間窗口只寫在 contentGoal
-
-## patrolKeywords(8~10 組,每組 6~14 字)
-- 這不是分類標籤,而是**真人會貼進 Threads 搜尋框的短句**
-- 每組 2~4 個詞,空格分隔;必須同時保留「情境/困擾」與「產品品類/用途」
-- 優先格式:「困擾 品類」、「族群 品類 推薦」、「症狀 品類 請問」,例如「化療 沐浴乳 推薦」「無香 洗衣精 請問」
-- 要能找到成果:過短、過廣、太像標籤的不要給(差:「癌症」「沐浴」「健康生活」「環境荷爾蒙」)
-- 不要整句複製 questions,也不要寫品牌名;搜尋句要像使用者會查「有沒有人在討論」的字
-
-## 優秀範例(請接近此密度、具體度與欄位長度)
-{
- "audienceSummary": "因荷爾蒙相關癌症(如乳癌、婦科癌症)正在治療中或康復後,對香精與化學成分特別敏感,洗澡或清潔時容易因香味而噁心、頭痛或皮膚不適,因此積極尋找「完全無香、抗敏、有第三方認證」沐浴與清潔產品的人。",
- "contentGoal": "找出「近期發文(理想 3 天內)」、作者本身因癌症或荷爾蒙治療導致對香味/化學成分敏感、有明確換沐浴乳或清潔產品需求、現在留言區自然推薦 ecostore 抗敏無香沐浴露還來得及且不突兀的貼文。",
- "questions": [
- "化療後皮膚敏感要換什麼沐浴乳",
- "乳癌治療中不能用有香味的沐浴乳嗎",
- "癌症病人適合用的無香沐浴乳推薦",
- "荷爾蒙治療皮膚乾癢怎麼挑沐浴乳",
- "打標靶後對香味很敏感怎麼辦",
- "康復後不想再用有香精的清潔用品",
- "癌症病友都用什麼牌子沐浴乳",
- "化療期間沐浴乳挑選經驗分享"
- ],
- "pillars": [
- "化療皮膚敏感無香沐浴乳",
- "乳癌病友沐浴用品挑選",
- "荷爾蒙治療對香味敏感",
- "癌症康復後換清潔品牌",
- "抗敏無香沐浴乳推薦",
- "化療期間皮膚照護"
- ],
- "exclusions": [
- "純曬照、純分享日常生活的貼文",
- "沒有求助、沒有換產品需求的閒聊",
- "只談癌症治療、確診心情但未提及清潔/沐浴困擾",
- "推廣其他品牌沐浴乳或業配他牌的貼文",
- "男性攝護腺癌、肺癌等與香味敏感較無直接相關的癌別(除非明確提到化學敏感)",
- "寵物洗毛精、洗碗精等其他品類",
- "已使用 ecostore 或他牌抗敏沐浴乳且滿意、無換品牌需求的貼文",
- "醫療專業衛教、營養師發文等非病友視角貼文"
- ],
- "patrolKeywords": [
- "化療 沐浴乳 推薦",
- "無香沐浴乳 請問",
- "乳癌 沐浴乳 推薦",
- "荷爾蒙 敏感 沐浴乳",
- "化療 皮膚 沐浴乳",
- "癌症 無香 沐浴乳",
- "病友 沐浴乳 推薦",
- "抗敏 沐浴乳 推薦"
- ]
-}`)
-}
-
-// BuildResearchMapUserPrompt is kept for tests; production uses analysis + finalize prompts.
-func BuildResearchMapUserPrompt(in ResearchMapInput) string {
- return BuildResearchMapFinalizeUserPrompt(in, "")
-}
-
-func ResearchMapJSONOnlyRetryPrompt() string {
- return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
-- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
-- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、patrolKeywords 欄位
-- 不要使用 ... 省略,也不要加註解或尾端逗號`)
-}
-
-func ResearchMapRetryUserPrompt() string {
- return strings.TrimSpace(`上次產出過於簡略或項目不足。請重新產出完整 JSON,密度對齊系統 prompt 優秀範例:
-- 必須緊扣【輸入資料】中的品牌、產品與主題目標,不可照搬範例用字
-- audienceSummary ≥80 字,contentGoal ≥50 字(contentGoal 要寫入完整產品名稱)
-- questions ≥8、pillars ≥6、exclusions ≥8(exclusions 不可含時間條件)
-- patrolKeywords 要像真人會在 Threads 搜尋框打的 2~4 詞短句,必須同時包含困擾與品類
-- 每句都要具體、可執行,不要用單一關鍵字敷衍`)
-}
-
-func ParseResearchMapOutput(raw string) (ResearchMap, error) {
- payload, err := extractResearchJSONObject(raw)
- if err != nil {
- return ResearchMap{}, err
- }
- var out ResearchMap
- if err := llmjson.Unmarshal(payload, &out); err != nil {
- return ResearchMap{}, fmt.Errorf("parse research map json: %w", err)
- }
- out.AudienceSummary = strings.TrimSpace(out.AudienceSummary)
- out.ContentGoal = strings.TrimSpace(out.ContentGoal)
- out.Questions = cleanStringList(out.Questions)
- out.Pillars = cleanStringList(out.Pillars)
- out.Exclusions = filterTimeExclusions(cleanStringList(out.Exclusions))
- out.PatrolKeywords = cleanStringList(out.PatrolKeywords)
- if out.AudienceSummary == "" && len(out.Questions) == 0 {
- return ResearchMap{}, fmt.Errorf("research map missing audience or questions")
- }
- return out, nil
-}
-
-func ResearchMapTooThin(m ResearchMap) bool {
- if utf8.RuneCountInString(m.AudienceSummary) < minAudienceRunes {
- return true
- }
- if utf8.RuneCountInString(strings.TrimSpace(m.ContentGoal)) < minContentGoalRunes {
- return true
- }
- if len(m.Questions) < minResearchQuestions {
- return true
- }
- if len(m.Pillars) < minResearchPillars {
- return true
- }
- if len(m.Exclusions) < minResearchExclusions {
- return true
- }
- return false
-}
-
-var exclusionTimePattern = regexp.MustCompile(`近期|過舊|太久|天內|天前|幾天|一週|上週|發文時間|非近期|多久以前|月份前|昨天|今天發文|時間窗口|理想\s*\d`)
-
-func filterTimeExclusions(items []string) []string {
- if len(items) == 0 {
- return items
- }
- out := make([]string, 0, len(items))
- for _, item := range items {
- if exclusionTimePattern.MatchString(item) {
- continue
- }
- out = append(out, item)
- }
- return out
-}
-
-func cleanStringList(items []string) []string {
- out := make([]string, 0, len(items))
- seen := map[string]struct{}{}
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- if _, ok := seen[item]; ok {
- continue
- }
- seen[item] = struct{}{}
- out = append(out, item)
- }
- return out
-}
-
-func extractResearchJSONObject(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := researchMapFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("research map output missing json object")
- }
- return []byte(raw[start : end+1]), nil
-}
-
-func ResearchMapTopicLabel(brandDisplayName, topicName string) string {
- if topic := strings.TrimSpace(topicName); topic != "" {
- return topic
- }
- return strings.TrimSpace(brandDisplayName)
-}
diff --git a/old/backend/internal/library/placement/research_map_test.go b/old/backend/internal/library/placement/research_map_test.go
deleted file mode 100644
index 1eb9076..0000000
--- a/old/backend/internal/library/placement/research_map_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package placement
-
-import "testing"
-
-func TestResearchMapTopicLabel(t *testing.T) {
- if got := ResearchMapTopicLabel("品牌A", "敏感肌換季"); got != "敏感肌換季" {
- t.Fatalf("got %q", got)
- }
- if got := ResearchMapTopicLabel("品牌A", ""); got != "品牌A" {
- t.Fatalf("got %q", got)
- }
-}
-
-func TestResearchMapTooThin(t *testing.T) {
- thin := ResearchMap{
- AudienceSummary: "太短",
- ContentGoal: "目標",
- Questions: []string{"q1"},
- Pillars: []string{"p1"},
- Exclusions: []string{"e1"},
- }
- if !ResearchMapTooThin(thin) {
- t.Fatal("expected thin map")
- }
- rich := ResearchMap{
- AudienceSummary: "這是一群在換季時因為敏感肌而臉頰泛紅刺痛、正在 Threads 上求助保養方式的年輕上班族,他們常因換產品或壓力而惡化,洗澡後更癢更紅,因此積極尋找溫和無香精、有第三方認證的修護產品。",
- ContentGoal: "找出「近期發文(理想 3 天內)」、作者本身有敏感肌舒緩困擾、有明確換修護產品需求、現在留言區自然推薦目標修護乳液還來得及且不突兀的貼文。",
- Questions: []string{"1", "2", "3", "4", "5", "6", "7", "8"},
- Pillars: []string{"1", "2", "3", "4", "5", "6"},
- Exclusions: []string{"1", "2", "3", "4", "5", "6", "7", "8"},
- }
- if ResearchMapTooThin(rich) {
- t.Fatal("expected rich map")
- }
-}
-
-func TestFilterTimeExclusions(t *testing.T) {
- items := []string{
- "純曬照、純分享日常生活的貼文",
- "過舊或非近期發文的貼文",
- "推廣其他品牌沐浴乳或業配他牌的貼文",
- }
- got := filterTimeExclusions(items)
- if len(got) != 2 {
- t.Fatalf("got %d items: %v", len(got), got)
- }
-}
-
-func TestBuildResearchMapSystemPromptHasSearchGuidance(t *testing.T) {
- prompt := BuildResearchMapSystemPrompt()
- for _, want := range []string{
- "questions", "pillars", "exclusions", "patrolKeywords", "Threads", "寧可詳盡",
- "ecostore 抗敏無香沐浴露", "化療後皮膚敏感要換什麼沐浴乳",
- "工作流程", "前置分析", "禁止寫時間相關條件",
- } {
- if !contains(prompt, want) {
- t.Fatalf("prompt missing %q", want)
- }
- }
-}
-
-func contains(s, sub string) bool {
- return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
-}
-
-func indexOf(s, sub string) int {
- for i := 0; i+len(sub) <= len(s); i++ {
- if s[i:i+len(sub)] == sub {
- return i
- }
- }
- return -1
-}
diff --git a/old/backend/internal/library/placement/resolve_media_exec.go b/old/backend/internal/library/placement/resolve_media_exec.go
deleted file mode 100644
index d486237..0000000
--- a/old/backend/internal/library/placement/resolve_media_exec.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package placement
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-)
-
-type resolveMediaInput struct {
- Permalink string `json:"permalink"`
- StorageState string `json:"storage_state,omitempty"`
-}
-
-type resolveMediaOutput struct {
- MediaID string `json:"mediaId"`
-}
-
-// RunResolveMediaFromPermalink opens a Threads post page via Playwright and extracts numeric media id.
-func RunResolveMediaFromPermalink(ctx context.Context, permalink, storageState string) (string, error) {
- permalink = strings.TrimSpace(permalink)
- if permalink == "" {
- return "", fmt.Errorf("permalink is required")
- }
- if endpoint := strings.TrimSpace(os.Getenv("HAIXUN_RESOLVE_MEDIA_URL")); endpoint != "" {
- return runHTTPResolveMedia(ctx, endpoint, permalink, storageState)
- }
-
- repoRoot, cliPath, err := resolveMediaCLI()
- if err != nil {
- return "", err
- }
-
- payload, err := json.Marshal(resolveMediaInput{
- Permalink: permalink,
- StorageState: strings.TrimSpace(storageState),
- })
- if err != nil {
- return "", err
- }
-
- runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
- defer cancel()
-
- cmd := exec.CommandContext(runCtx, "npx", "tsx", cliPath)
- cmd.Dir = repoRoot
- cmd.Stdin = bytes.NewReader(payload)
- 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 "", fmt.Errorf("permalink media resolve failed: %s", msg)
- }
-
- var out resolveMediaOutput
- if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
- return "", fmt.Errorf("permalink media resolve output parse failed: %w", err)
- }
- mediaID := strings.TrimSpace(out.MediaID)
- if mediaID == "" {
- return "", fmt.Errorf("permalink media resolve returned empty media id")
- }
- return mediaID, nil
-}
-
-func runHTTPResolveMedia(ctx context.Context, endpoint, permalink, storageState string) (string, error) {
- payload, err := json.Marshal(resolveMediaInput{Permalink: permalink, StorageState: strings.TrimSpace(storageState)})
- if err != nil {
- return "", err
- }
- runCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
- defer cancel()
- url := strings.TrimRight(endpoint, "/") + "/resolve"
- req, err := http.NewRequestWithContext(runCtx, http.MethodPost, url, bytes.NewReader(payload))
- if err != nil {
- return "", err
- }
- req.Header.Set("Content-Type", "application/json")
- res, err := http.DefaultClient.Do(req)
- if err != nil {
- return "", fmt.Errorf("permalink media resolve service failed: %w", err)
- }
- defer res.Body.Close()
- body, _ := io.ReadAll(res.Body)
- if res.StatusCode < 200 || res.StatusCode >= 300 {
- return "", fmt.Errorf("permalink media resolve service returned HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
- }
- var out resolveMediaOutput
- if err := json.Unmarshal(body, &out); err != nil {
- return "", fmt.Errorf("permalink media resolve service output parse failed: %w", err)
- }
- mediaID := strings.TrimSpace(out.MediaID)
- if mediaID == "" {
- return "", fmt.Errorf("permalink media resolve service returned empty media id")
- }
- return mediaID, nil
-}
-
-func resolveMediaCLI() (repoRoot, cliPath string, err error) {
- if root := strings.TrimSpace(os.Getenv("HAIXUN_REPO_ROOT")); root != "" {
- cli := filepath.Join(root, "haixun-backend", "worker", "threads-resolve-media-cli.ts")
- if fileExists(cli) {
- return root, cli, nil
- }
- cli = filepath.Join(root, "worker", "threads-resolve-media-cli.ts")
- if fileExists(cli) {
- return root, cli, nil
- }
- }
-
- cwd, err := os.Getwd()
- if err != nil {
- return "", "", fmt.Errorf("resolve media cli: %w", err)
- }
- dir := cwd
- for i := 0; i < 6; i++ {
- cli := filepath.Join(dir, "haixun-backend", "worker", "threads-resolve-media-cli.ts")
- if fileExists(cli) {
- return dir, cli, nil
- }
- cli = filepath.Join(dir, "worker", "threads-resolve-media-cli.ts")
- if fileExists(cli) {
- return dir, cli, nil
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- break
- }
- dir = parent
- }
- return "", "", fmt.Errorf("找不到 threads-resolve-media-cli.ts,請設定 HAIXUN_REPO_ROOT")
-}
diff --git a/old/backend/internal/library/placement/resolve_reply_target.go b/old/backend/internal/library/placement/resolve_reply_target.go
deleted file mode 100644
index b6d50e8..0000000
--- a/old/backend/internal/library/placement/resolve_reply_target.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package placement
-
-import "strings"
-
-func ResolveReplyTargetID(externalID, permalink string) string {
- if id := strings.TrimSpace(externalID); id != "" {
- return id
- }
- permalink = normalizeThreadsPermalink(permalink)
- if permalink == "" {
- return ""
- }
- match := threadsPostURLRe.FindStringSubmatch(permalink)
- if len(match) < 3 {
- return ""
- }
- return strings.TrimSpace(match[2])
-}
diff --git a/old/backend/internal/library/placement/resolve_reply_target_test.go b/old/backend/internal/library/placement/resolve_reply_target_test.go
deleted file mode 100644
index 267a0c2..0000000
--- a/old/backend/internal/library/placement/resolve_reply_target_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package placement
-
-import "testing"
-
-func TestResolveReplyTargetIDFromPermalink(t *testing.T) {
- got := ResolveReplyTargetID("", "https://www.threads.net/@alice/post/AbCdEf123")
- if got != "AbCdEf123" {
- t.Fatalf("got %q", got)
- }
-}
-
-func TestResolveReplyTargetIDPrefersExternalID(t *testing.T) {
- got := ResolveReplyTargetID("media-123", "https://www.threads.net/@alice/post/AbCdEf123")
- if got != "media-123" {
- t.Fatalf("got %q", got)
- }
-}
diff --git a/old/backend/internal/library/placement/scrape_replies.go b/old/backend/internal/library/placement/scrape_replies.go
deleted file mode 100644
index bd64c79..0000000
--- a/old/backend/internal/library/placement/scrape_replies.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package placement
-
-import (
- "context"
- "strings"
-
- libthreads "haixun-backend/internal/library/threadsapi"
-)
-
-const replyFetchTopPosts = 10
-
-// ReplyCandidate is a normalized reply attached to a scan post.
-type ReplyCandidate struct {
- ExternalID string
- Author string
- Text string
- Permalink string
- LikeCount int
- PostedAt string
-}
-
-type ScrapeRepliesInput struct {
- Posts []ScanCandidate
- Member MemberContext
- RepliesPerPost int
- MaxPosts int
-}
-
-// AttachReplies fetches replies for top-priority posts when scrape is enabled.
-func AttachReplies(ctx context.Context, input ScrapeRepliesInput) []ScanCandidate {
- if len(input.Posts) == 0 {
- return input.Posts
- }
- perPost := input.RepliesPerPost
- if perPost <= 0 {
- perPost = 10
- }
- if perPost > 25 {
- perPost = 25
- }
- maxPosts := input.MaxPosts
- if maxPosts <= 0 {
- maxPosts = replyFetchTopPosts
- }
-
- targets := pickReplyTargets(input.Posts, maxPosts)
- if len(targets) == 0 {
- return input.Posts
- }
-
- client := libthreads.NewClient(input.Member.ThreadsAPIAccessToken)
- byKey := make(map[string][]ReplyCandidate, len(targets))
- for _, target := range targets {
- externalID := strings.TrimSpace(target.ExternalID)
- if externalID == "" {
- continue
- }
- var replies []ReplyCandidate
- if input.Member.ApiConnected && input.Member.ThreadsAPIAccessToken != "" {
- items, err := client.MediaReplies(ctx, externalID, perPost)
- if err == nil {
- for _, item := range items {
- text := strings.TrimSpace(item.Text)
- if text == "" {
- continue
- }
- replies = append(replies, ReplyCandidate{
- ExternalID: strings.TrimSpace(item.ID),
- Author: strings.TrimSpace(item.Username),
- Text: text,
- Permalink: strings.TrimSpace(item.Permalink),
- LikeCount: item.LikeCount,
- PostedAt: strings.TrimSpace(item.Timestamp),
- })
- }
- }
- }
- key := candidateKey(target)
- byKey[key] = replies
- }
-
- out := make([]ScanCandidate, 0, len(input.Posts))
- for _, post := range input.Posts {
- key := candidateKey(post)
- if replies, ok := byKey[key]; ok && len(replies) > 0 {
- post.Replies = replies
- }
- out = append(out, post)
- }
- return out
-}
-
-func pickReplyTargets(posts []ScanCandidate, maxPosts int) []ScanCandidate {
- ranked := append([]ScanCandidate(nil), posts...)
- for i := 0; i < len(ranked); i++ {
- for j := i + 1; j < len(ranked); j++ {
- if replyTargetRank(ranked[j]) > replyTargetRank(ranked[i]) {
- ranked[i], ranked[j] = ranked[j], ranked[i]
- }
- }
- }
- out := make([]ScanCandidate, 0, maxPosts)
- for _, post := range ranked {
- if strings.TrimSpace(post.ExternalID) == "" && strings.TrimSpace(post.Permalink) == "" {
- continue
- }
- out = append(out, post)
- if len(out) >= maxPosts {
- break
- }
- }
- return out
-}
-
-func replyTargetRank(post ScanCandidate) int {
- switch post.Priority {
- case "gold":
- return 300 + post.PlacementScore
- case "recent":
- return 200 + post.PlacementScore
- case "relevant":
- return 100 + post.PlacementScore
- default:
- return post.PlacementScore
- }
-}
-
-func candidateKey(post ScanCandidate) string {
- if id := strings.TrimSpace(post.ExternalID); id != "" {
- return "id:" + id
- }
- return "url:" + strings.TrimSpace(post.Permalink)
-}
diff --git a/old/backend/internal/library/placement/scrape_replies_test.go b/old/backend/internal/library/placement/scrape_replies_test.go
deleted file mode 100644
index 2c8e611..0000000
--- a/old/backend/internal/library/placement/scrape_replies_test.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package placement
-
-import "testing"
-
-func TestReplyTargetRankPrefersGold(t *testing.T) {
- gold := ScanCandidate{Priority: "gold", PlacementScore: 10}
- recent := ScanCandidate{Priority: "recent", PlacementScore: 90}
- if replyTargetRank(recent) >= replyTargetRank(gold) {
- t.Fatalf("expected gold to outrank recent")
- }
-}
-
-func TestPickReplyTargetsRespectsLimit(t *testing.T) {
- posts := make([]ScanCandidate, 0, 15)
- for i := 0; i < 15; i++ {
- posts = append(posts, ScanCandidate{
- ExternalID: "id-" + string(rune('a'+i)),
- Priority: "relevant",
- })
- }
- targets := pickReplyTargets(posts, 5)
- if len(targets) != 5 {
- t.Fatalf("expected 5 targets, got %d", len(targets))
- }
-}
diff --git a/old/backend/internal/library/placement/source_mode.go b/old/backend/internal/library/placement/source_mode.go
deleted file mode 100644
index 2f5f2db..0000000
--- a/old/backend/internal/library/placement/source_mode.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package placement
-
-import "strings"
-
-// SearchSourceMode mirrors the legacy Next.js search source options per Threads account.
-type SearchSourceMode string
-
-const (
- SearchSourceMixed SearchSourceMode = "mixed"
- SearchSourceThreads SearchSourceMode = "threads"
- SearchSourceBrave SearchSourceMode = "brave"
- SearchSourceCrawler SearchSourceMode = "crawler"
- SearchSourceThreadsBrave SearchSourceMode = "threads_brave"
- SearchSourceThreadsCrawler SearchSourceMode = "threads_crawler"
- SearchSourceBraveCrawler SearchSourceMode = "brave_crawler"
-)
-
-const DefaultSearchSourceMode = SearchSourceMixed
-
-func ParseSearchSourceMode(raw string) SearchSourceMode {
- switch SearchSourceMode(strings.TrimSpace(raw)) {
- case SearchSourceMixed, SearchSourceThreads, SearchSourceBrave, SearchSourceCrawler,
- SearchSourceThreadsBrave, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
- return SearchSourceMode(strings.TrimSpace(raw))
- default:
- return DefaultSearchSourceMode
- }
-}
-
-func ModeAllowsThreadsAPI(mode SearchSourceMode) bool {
- switch mode {
- case SearchSourceMixed, SearchSourceThreads, SearchSourceThreadsBrave, SearchSourceThreadsCrawler:
- return true
- default:
- return false
- }
-}
-
-func ModeAllowsBrave(mode SearchSourceMode) bool {
- switch mode {
- case SearchSourceMixed, SearchSourceBrave, SearchSourceThreadsBrave, SearchSourceBraveCrawler:
- return true
- default:
- return false
- }
-}
-
-func ModeAllowsCrawler(mode SearchSourceMode) bool {
- switch mode {
- case SearchSourceMixed, SearchSourceCrawler, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
- return true
- default:
- return false
- }
-}
-
-// MemberNeedsWebSearchKey reports whether placement scan should require a web search API key.
-func MemberNeedsWebSearchKey(ctx MemberContext) bool {
- if !ctx.AllowsBrave {
- return false
- }
- switch ctx.SearchSourceMode {
- case SearchSourceBrave, SearchSourceThreadsBrave:
- return true
- case SearchSourceThreads:
- return false
- default:
- return ctx.AllowsBrave
- }
-}
-
-// MemberNeedsBraveKey is deprecated; use MemberNeedsWebSearchKey.
-func MemberNeedsBraveKey(ctx MemberContext) bool {
- return MemberNeedsWebSearchKey(ctx)
-}
-
-// WithoutCrawler returns a mode that never uses Playwright, for formal API-only routing.
-func WithoutCrawler(mode SearchSourceMode) SearchSourceMode {
- switch mode {
- case SearchSourceMixed, SearchSourceThreadsCrawler, SearchSourceBraveCrawler:
- return SearchSourceThreadsBrave
- case SearchSourceCrawler:
- return SearchSourceThreads
- default:
- return mode
- }
-}
diff --git a/old/backend/internal/library/placement/source_mode_test.go b/old/backend/internal/library/placement/source_mode_test.go
deleted file mode 100644
index 3b5b0ed..0000000
--- a/old/backend/internal/library/placement/source_mode_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package placement
-
-import "testing"
-
-func TestWithoutCrawlerStripsBrowserModes(t *testing.T) {
- if got := WithoutCrawler(SearchSourceMixed); got != SearchSourceThreadsBrave {
- t.Fatalf("mixed -> threads_brave, got %s", got)
- }
- if got := WithoutCrawler(SearchSourceCrawler); got != SearchSourceThreads {
- t.Fatalf("crawler -> threads, got %s", got)
- }
-}
-
-func TestMemberNeedsBraveKey(t *testing.T) {
- threadsOnly := MemberContext{AllowsBrave: false, AllowsThreadsAPI: true, SearchSourceMode: SearchSourceThreads}
- if MemberNeedsBraveKey(threadsOnly) {
- t.Fatal("threads-only should not require brave key")
- }
- braveOnly := MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave}
- if !MemberNeedsBraveKey(braveOnly) {
- t.Fatal("brave-only should require brave key")
- }
- testPatrol := MemberContextForCrawlerOnly(MemberContext{AllowsBrave: true, SearchSourceMode: SearchSourceBrave})
- if MemberNeedsBraveKey(testPatrol) {
- t.Fatal("test patrol crawler should not require brave key")
- }
-}
-
-func TestBuildMemberContextFormalModeRespectsSearchSource(t *testing.T) {
- prefs := ConnectionPrefsInput{
- DevMode: false,
- SearchSourceMode: string(SearchSourceMixed),
- }
- ctx := BuildMemberContext("t", "u", "acc", prefs, true, false, ResearchSettings{}, false, 10)
- if !ctx.AllowsCrawler {
- t.Fatal("mixed mode should allow crawler when browser is connected")
- }
- if ctx.SearchSourceMode != SearchSourceMixed {
- t.Fatalf("expected mixed, got %s", ctx.SearchSourceMode)
- }
-}
diff --git a/old/backend/internal/library/placement/threads_api.go b/old/backend/internal/library/placement/threads_api.go
deleted file mode 100644
index aff4eb6..0000000
--- a/old/backend/internal/library/placement/threads_api.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package placement
-
-import (
- "context"
- "fmt"
- "strings"
-
- libkg "haixun-backend/internal/library/knowledge"
- libthreads "haixun-backend/internal/library/threadsapi"
-)
-
-func keywordSearchViaThreadsAPI(ctx context.Context, req DiscoverRequest) ([]DiscoverPost, error) {
- token := strings.TrimSpace(req.Member.ThreadsAPIAccessToken)
- if token == "" {
- return nil, fmt.Errorf("threads api access token not configured for active account")
- }
-
- client := libthreads.NewClient(token)
- searchType := "TOP"
- if req.Recency {
- searchType = "RECENT"
- }
- limit := req.Limit
- if limit <= 0 {
- limit = 12
- }
-
- query := strings.TrimSpace(libkg.ThreadsAPIKeyword(req.Keyword))
- if query == "" {
- query = strings.TrimSpace(req.Query)
- query = strings.TrimPrefix(query, "site:threads.net ")
- query = strings.Trim(query, `"`)
- if idx := strings.Index(query, " after:"); idx > 0 {
- query = strings.TrimSpace(query[:idx])
- }
- query = libkg.ShortPatrolSearchCore(strings.Trim(query, `"`))
- }
- if query == "" {
- return nil, fmt.Errorf("threads api keyword is empty")
- }
-
- items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
- Query: query,
- Limit: limit,
- SearchType: searchType,
- })
- if err != nil {
- return nil, err
- }
-
- out := make([]DiscoverPost, 0, len(items))
- for _, item := range items {
- text := strings.TrimSpace(item.Text)
- if text == "" {
- continue
- }
- externalID := strings.TrimSpace(item.ID)
- if !libthreads.IsNumericMediaID(externalID) {
- continue
- }
- permalink := strings.TrimSpace(item.Permalink)
- if permalink == "" && item.Username != "" {
- permalink = "https://www.threads.net/@" + item.Username + "/post/" + externalID
- }
- out = append(out, DiscoverPost{
- Text: text,
- Permalink: permalink,
- ExternalID: externalID,
- Author: strings.TrimSpace(item.Username),
- PostedAt: strings.TrimSpace(item.Timestamp),
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- Source: DiscoverThreadsAPI,
- })
- }
- return out, nil
-}
diff --git a/old/backend/internal/library/placement/topic_fit.go b/old/backend/internal/library/placement/topic_fit.go
deleted file mode 100644
index a068582..0000000
--- a/old/backend/internal/library/placement/topic_fit.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package placement
-
-import (
- "strings"
-
- libkg "haixun-backend/internal/library/knowledge"
-)
-
-// HasTangentialTopicMismatch uses the shared intent model: broad category overlap
-// without product/research-map intent alignment (generalizes beyond hardcoded lists).
-func HasTangentialTopicMismatch(text string, ctx PostScanContext) bool {
- text = strings.TrimSpace(text)
- if text == "" {
- return false
- }
- if matchesTopicAnchor(text, ctx) {
- return false
- }
- profile := libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx))
- return libkg.IsTangentialToIntent(text, profile)
-}
-
-func PatrolTagInputFromScanContext(ctx PostScanContext) libkg.PatrolTagInput {
- return intentProfileInputFromPostScan(ctx)
-}
-
-func intentProfileInputFromPostScan(ctx PostScanContext) libkg.PatrolTagInput {
- return libkg.PatrolTagInput{
- ProductName: ctx.ProductName,
- ProductFeatures: ctx.ProductFeatures,
- AudienceSummary: ctx.AudienceSummary,
- TargetAudience: ctx.TargetAudience,
- MatchTags: append([]string{}, ctx.MatchTags...),
- Questions: append([]string{}, ctx.Questions...),
- Pillars: append([]string{}, ctx.Pillars...),
- }
-}
-
-// LocalIntentScore is the offline semantic scorer (no embedding API).
-func LocalIntentScore(text string, ctx PostScanContext) int {
- return libkg.ScoreIntentSimilarity(text, libkg.BuildIntentProfile(intentProfileInputFromPostScan(ctx)))
-}
diff --git a/old/backend/internal/library/prompt/compose.go b/old/backend/internal/library/prompt/compose.go
deleted file mode 100644
index 4d5e94d..0000000
--- a/old/backend/internal/library/prompt/compose.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package prompt
-
-import (
- "encoding/json"
- "strings"
-)
-
-const overlaySeparator = "\n\n---\n\n"
-
-// ComposeSystem prepends global overlay to the base system prompt before provider send.
-func ComposeSystem(base string) (string, error) {
- base = strings.TrimSpace(base)
- overlayText, err := Overlay()
- if err != nil {
- return "", err
- }
- overlayText = strings.TrimSpace(overlayText)
- if overlayText == "" {
- return base, nil
- }
- if base == "" {
- return overlayText, nil
- }
- return overlayText + overlaySeparator + base, nil
-}
-
-// Style8DSystem loads 8D slots from config files and composes the outgoing system prompt.
-func Style8DSystem() (string, error) {
- system, err := Slot(KeyStyle8DSystem)
- if err != nil {
- return "", err
- }
- schema, err := Slot(KeyStyle8DSchema)
- if err != nil {
- return "", err
- }
- return ComposeSystem(system + schema)
-}
-
-// IslanderSystem composes the islander guide system prompt with optional live page context.
-func IslanderSystem(pageContext string) (string, error) {
- base, err := Slot(KeyIslanderSystem)
- if err != nil {
- return "", err
- }
- pageContext = strings.TrimSpace(pageContext)
- if pageContext != "" {
- base = base + "\n\n---\n\n" + pageContext
- }
- return ComposeSystem(base)
-}
-
-// KnowledgeGraphSystem composes the TKG synthesis system prompt.
-func KnowledgeGraphSystem() (string, error) {
- base, err := Slot(KeyKnowledgeGraphSystem)
- if err != nil {
- return "", err
- }
- return ComposeSystem(base)
-}
-
-// KnowledgeGraphLLMSystem composes the LLM-only TKG synthesis system prompt.
-func KnowledgeGraphLLMSystem() (string, error) {
- base, err := Slot(KeyKnowledgeGraphLLMSystem)
- if err != nil {
- return "", err
- }
- return ComposeSystem(base)
-}
-
-// KnowledgeGraphUser renders the TKG user prompt from the template slot.
-func KnowledgeGraphUser(vars map[string]string) (string, error) {
- base, err := Slot(KeyKnowledgeGraphUser)
- if err != nil {
- return "", err
- }
- return renderTemplate(base, vars), nil
-}
-
-// KnowledgeGraphLLMUser renders the LLM-only TKG user prompt.
-func KnowledgeGraphLLMUser(vars map[string]string) (string, error) {
- base, err := Slot(KeyKnowledgeGraphLLMUser)
- if err != nil {
- return "", err
- }
- return renderTemplate(base, vars), nil
-}
-
-// KnowledgeGraphSupplemental returns the supplemental-round instruction prompt.
-func KnowledgeGraphSupplemental() (string, error) {
- return Slot(KeyKnowledgeGraphSupplemental)
-}
-
-// KnowledgeGraphQueryConfig loads query template config from prompt files.
-func KnowledgeGraphQueryConfig() (map[string]json.RawMessage, error) {
- raw, err := readFile(fileKnowledgeGraphQueries)
- if err != nil {
- return nil, err
- }
- out := map[string]json.RawMessage{}
- if err := json.Unmarshal([]byte(raw), &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func renderTemplate(base string, vars map[string]string) string {
- out := base
- for key, value := range vars {
- out = strings.ReplaceAll(out, "{{"+key+"}}", value)
- }
- return strings.TrimSpace(out)
-}
-
-// OutreachPlacementSystem composes placement outreach comment system prompt.
-func OutreachPlacementSystem() (string, error) {
- base, err := Slot(KeyOutreachPlacementSystem)
- if err != nil {
- return "", err
- }
- return ComposeSystem(base)
-}
-
-// OutreachPlacementUser renders outreach user prompt from template slot.
-func OutreachPlacementUser(vars map[string]string) (string, error) {
- base, err := Slot(KeyOutreachPlacementUser)
- if err != nil {
- return "", err
- }
- return renderTemplate(base, vars), nil
-}
-
-// MatrixPlacementSystem composes content matrix system prompt.
-func MatrixPlacementSystem() (string, error) {
- base, err := Slot(KeyMatrixPlacementSystem)
- if err != nil {
- return "", err
- }
- return ComposeSystem(base)
-}
-
-// MatrixPlacementUser renders matrix user prompt from template slot.
-func MatrixPlacementUser(vars map[string]string) (string, error) {
- base, err := Slot(KeyMatrixPlacementUser)
- if err != nil {
- return "", err
- }
- return renderTemplate(base, vars), nil
-}
-
-// AIChatSystem composes the outgoing system prompt for console AI chat.
-func AIChatSystem(clientSystem string) (string, error) {
- base := strings.TrimSpace(clientSystem)
- if base == "" {
- var err error
- base, err = Slot(KeyAIChatSystem)
- if err != nil {
- return "", err
- }
- }
- return ComposeSystem(base)
-}
diff --git a/old/backend/internal/library/prompt/compose_test.go b/old/backend/internal/library/prompt/compose_test.go
deleted file mode 100644
index 3fd1266..0000000
--- a/old/backend/internal/library/prompt/compose_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package prompt
-
-import (
- "strings"
- "testing"
-)
-
-func TestComposeSystemOverlayFirst(t *testing.T) {
- got, err := ComposeSystem("基礎 system")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(got, "基礎 system") {
- t.Fatalf("missing base: %q", got)
- }
- if !strings.Contains(got, overlaySeparator) {
- t.Fatalf("missing separator: %q", got)
- }
-}
-
-func TestStyle8DSystemIncludesSchema(t *testing.T) {
- got, err := Style8DSystem()
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(got, "創作者「語言指紋」研究員") || !strings.Contains(got, `"d1Tone"`) {
- t.Fatalf("missing expected fragments: %q", got)
- }
-}
-
-func TestIslanderSystemIncludesContext(t *testing.T) {
- got, err := IslanderSystem("【目前頁面】\n- 路徑:/settings")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(got, "島民嚮導") || !strings.Contains(got, "/settings") {
- t.Fatalf("missing expected fragments: %q", got)
- }
-}
-
-func TestAIChatSystemUsesDefault(t *testing.T) {
- got, err := AIChatSystem("")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(got, "巡樓管理台") {
- t.Fatalf("got %q", got)
- }
-}
diff --git a/old/backend/internal/library/prompt/errors.go b/old/backend/internal/library/prompt/errors.go
deleted file mode 100644
index 8c4b48b..0000000
--- a/old/backend/internal/library/prompt/errors.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package prompt
-
-import "fmt"
-
-type unknownKeyError struct {
- key string
-}
-
-func ErrUnknownKey(key string) error {
- return unknownKeyError{key: key}
-}
-
-func (e unknownKeyError) Error() string {
- return fmt.Sprintf("unknown prompt key: %s", e.key)
-}
diff --git a/old/backend/internal/library/prompt/files/ai.chat.system.md b/old/backend/internal/library/prompt/files/ai.chat.system.md
deleted file mode 100644
index 361e981..0000000
--- a/old/backend/internal/library/prompt/files/ai.chat.system.md
+++ /dev/null
@@ -1 +0,0 @@
-你是巡樓管理台的 AI 助手。回答要簡潔、可執行,優先協助島民完成 Threads 經營與背景任務相關問題。
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/ai.islander.system.md b/old/backend/internal/library/prompt/files/ai.islander.system.md
deleted file mode 100644
index 24c9a7c..0000000
--- a/old/backend/internal/library/prompt/files/ai.islander.system.md
+++ /dev/null
@@ -1,112 +0,0 @@
-你是巡樓管理台的「島民嚮導」——親切、直接,而且**可以代使用者操作畫面**。
-
-## 任務
-- 回答使用者提出的問題
-- 需要操作畫面時**直接做**,不要叫使用者自己 copy、自己點
-
-## 不要主動講這一頁
-- 使用者**沒問**這頁、畫面、欄位、怎麼用時,不要主動介紹「你現在在某某頁」
-- 只有使用者問這頁、問怎麼寫欄位內容、或要你幫忙操作時,才使用【可互動元素】
-
-## 靜默操作(重要)
-- `islander-actions` 區塊是**系統通道**,使用者**看不到**;禁止在回覆正文裡寫 JSON、程式碼、ref 清單
-- 不要說「我會執行以下操作」「請看 action 區塊」;用人話簡短說結果即可
-- 需要 navigate / click / fill 時,把 action 只放在 `islander-actions` 區塊末尾,正文維持自然對話
-
-## 幫使用者寫進欄位
-當使用者問「某某欄位可以怎麼寫」(例如人設頁的「一句話定位」):
-1. 先用 1–3 句說明思路或給建議文案
-2. 從【可互動元素】找到對應 textarea(看 label / placeholder,如「一句話定位」)
-3. 用 `fill` 把建議文字**直接填進欄位**,不要叫使用者自己貼
-4. 正文**必須寫出建議文案全文**(方便使用者複製),結尾再說「我也幫你填進去了,可以再微調」
-
-範例(正文給使用者看的):
-「這個帳號可以定位成:幫想轉職的工程師,用真實面試經驗拆解求職焦慮。我幫你填進一句話定位了,不滿意再跟我說。」
-
-範例(僅系統執行,放區塊末尾、勿在正文重複):
-
-```islander-actions
-[{ "type": "fill", "label": "一句話定位", "value": "幫想轉職的工程師,用真實面試經驗拆解求職焦慮" }]
-```
-
-`fill` 可用 `label`(對應欄位名稱,如「一句話定位」)或 `ref`(hx-*)。
-
-## 支援的 action
-- `navigate` / `click` / `fill` / `select` / `focus` / `highlight` / `scroll` / `wait`
-- ref 只能來自快照中的 `hx-*`;密碼欄不可 fill;不要操作登出
-
-## 語氣
-- 繁體中文,短句
-- 不要企業八股、不要任天堂/Nook 用語
-
-## 限制
-- 不要要求使用者貼 API key
-
-## 兩條工作流(必讀,勿混淆)
-
-| 工作流 | 入口 | 目的 | 關鍵實體 |
-|------|------|------|----------|
-| **拷貝忍者** | `/matrix` | 海巡爆款、學對標風格、產**仿寫**草稿 | 人設 + 8D 對標帳號 |
-| **找 TA** | `/outreach`(子步驟:研究→找TA留言) | 找痛點、productFit、產**獲客留言** | 品牌 + 人設語氣 |
-
-分流規則:
-- 使用者在 `/matrix` 或問仿寫/爆款/對標 → **只談拷貝忍者**,navigate 人設庫或拷貝忍者;**禁止** `expandKnowledgeGraph`、`startScan`、`generateOutreachReply`
-- 使用者在 `/research`、`/outreach` 或問痛點/產品置入 → **只談找 TA**;**禁止**建議 8D 對標當主要解法
-- 原創矩陣屬於拷貝忍者的 `/matrix`,不要在找 TA 裡推薦或顯示 `/brand-matrix`
-- 「海巡來源模式」(search_source_mode)是 API/爬蟲管道,**不是**拷貝忍者/找 TA 的區分
-
-## 拷貝忍者
-
-- 入口:`/matrix`(仿寫草稿庫 + 爆款海巡)
-- 對標與 8D:人設詳情 `/personas/:id#style-8d`
-- 爆款海巡:`startViralScan`(可搭配頁面「爆款關鍵字」欄位;留空則用對標帳號推導)
-- 仿寫草稿:`generateCopyDraft`(需 `scan_post_id`,來自爆款候選列表)
-- 引導 8D:`navigateToPersona8D` 或 `navigate` 到 `/personas/:id#style-8d`
-
-範例(拷貝忍者):
-
-```islander-actions
-[
- { "type": "startViralScan" },
- { "type": "generateCopyDraft", "scan_post_id": "貼文ID" },
- { "type": "navigateToPersona8D" }
-]
-```
-
-
-## 找 TA(研究頁 / 獲客台)
-
-### 海巡研究頁(`/research`)
-- 擴展圖譜:`expandKnowledgeGraph`(`seed_query` 可省略=用頁面種子詞;`supplemental=true` 補充痛點)
-- 勾選節點:`toggleGraphNode`(`node_id` + `selected`)
-- 啟動海巡:`startScan`(會先儲存勾選,再跑雙軌海巡)
-- 痛點 tag 不足(<8)時可建議 supplemental 擴展
-
-範例(研究頁):
-
-```islander-actions
-[
- { "type": "expandKnowledgeGraph", "seed_query": "敏感肌", "supplemental": false },
- { "type": "toggleGraphNode", "node_id": "節點ID", "selected": true },
- { "type": "startScan" }
-]
-```
-
-### 獲客台
-- 依 `productFitScore` 與 gold/recent 優先級建議留言對象
-- 寫獲客留言:`generateOutreachReply`(需 `scan_post_id`,可選 `count`)
-- 填入草稿:`applyDraft` 或 `fill` + label「獲客留言草稿」
-- 發送留言:`publishOutreach`(需 `scan_post_id`、`text`、`confirm=true`;僅 Threads API 已連線時)
-- 標記狀態:`markHandled`(`status` 可為 handled / skipped / pending)
-- 使用者未明確要求發送時,**不要**自動 publishOutreach
-
-範例(獲客台):
-
-```islander-actions
-[
- { "type": "generateOutreachReply", "scan_post_id": "貼文ID" },
- { "type": "applyDraft", "value": "(首則草稿全文)" },
- { "type": "publishOutreach", "scan_post_id": "貼文ID", "text": "(全文)", "confirm": true },
- { "type": "markHandled", "scan_post_id": "貼文ID", "status": "handled" }
-]
-```
diff --git a/old/backend/internal/library/prompt/files/knowledge_graph.queries.json b/old/backend/internal/library/prompt/files/knowledge_graph.queries.json
deleted file mode 100644
index 7e830f1..0000000
--- a/old/backend/internal/library/prompt/files/knowledge_graph.queries.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
- "max_plan_queries": 6,
- "hybrid_max_plan_queries": 4,
- "max_supplemental_queries": 2,
- "hybrid_max_supplemental_queries": 0,
- "results_per_query": 6,
- "min_sources_before_stop": 14,
- "max_sources_cap": 22,
- "brave_collect_concurrency": 3,
- "max_patrol_keyword_queries": 3,
- "max_question_queries": 2,
- "max_pillar_queries": 2,
- "max_plan_base_queries": 2,
- "max_peripheral_queries": 1,
- "max_l1_labels": 1,
- "min_pain_tag_candidates": 10,
- "min_total_tag_candidates": 18,
- "plan_base": [
- "{{seed}} 求助 推薦",
- "{{seed}} 怎麼改善 困擾",
- "{{seed}} 常見原因",
- "{{seed}} 請問"
- ],
- "plan_peripheral": [
- "{{seed}} 治療 副作用",
- "{{seed}} 日常 困擾",
- "{{seed}} 換季 泛紅"
- ],
- "plan_audience": "{{seed}} 與 {{audience}} 的關係",
- "plan_l1_cause": "{{label}} 原因",
- "plan_l1_pain": "{{label}} 困擾",
- "plan_pillar": "{{pillar}} 請問",
- "plan_question": "{{question}}",
- "supplemental": [
- "{{seed}} 困擾",
- "{{seed}} 求助",
- "{{seed}} 推薦",
- "{{seed}} 請問",
- "{{seed}} 經驗",
- "{{seed}} 怎麼辦"
- ],
- "supplemental_l1": "{{label}} 請問",
- "supplemental_pillar": "{{pillar}} 困擾 推薦",
- "recency_suffix": "請問",
- "recency_help_markers": "請問請益推薦求助"
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/knowledge_graph.supplemental.md b/old/backend/internal/library/prompt/files/knowledge_graph.supplemental.md
deleted file mode 100644
index 0a26f0d..0000000
--- a/old/backend/internal/library/prompt/files/knowledge_graph.supplemental.md
+++ /dev/null
@@ -1,6 +0,0 @@
-上次節點不足或廣度不夠。請**維持既有圖譜節點**,只追加新節點與邊:
-- 至少再增加 **10~14 個**節點,總數目標 **24~32 個**
-- 優先補 L2 周邊情境(不同治療階段、生活事件、相鄰困擾、相關品類)
-- 也要補 L1 成因/症狀/機制,讓圖譜能觸及更多討論方向
-- 新節點的 relation 與 placementValue 必須各 25~55 字的完整句子,不要 high/medium/low
-- 只追加,不要刪除或覆蓋既有節點
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/knowledge_graph.system.md b/old/backend/internal/library/prompt/files/knowledge_graph.system.md
deleted file mode 100644
index 1520ddb..0000000
--- a/old/backend/internal/library/prompt/files/knowledge_graph.system.md
+++ /dev/null
@@ -1,76 +0,0 @@
-你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述、主題目標**與外部參考 snippet,建立三層 Topic Knowledge Graph(TKG),幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。
-
-## 輸入理解(先做再想)
-- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色
-- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容
-
-## 設計目標:廣度優先
-- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向
-- 不要只圍繞核心痛點寫 5~6 個節點;**L2 周邊情境要比 L1 更多**
-- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點**
-
-## 產出密度(重要)
-- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡**
-- 每個節點的 `relation` 與 `placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替
-- 節點總數目標 **24~32 個**(含 L0/L1/L2)
-
-## 圖譜層級與數量
-- **L0 核心**:種子詞本身(**1** 個 pain 節點)
-- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個)
-- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個)
-- 痛點/求助類(pain、symptom、cause)合計 **至少 10 個**
-
-## 節點撰寫規則
-- `label`:2~14 字,具體、可拿去 Threads 找討論,不要空泛詞(❌「保養」✅「換季臉頰泛紅」)
-- `nodeKind`:`pain`(痛點/求助)、`symptom`、`cause`、`knowledge`(科普延伸)
-- `relation`:**必填**,1~2 句說明此節點與種子詞的脈絡(**25~55 字**)。例:「換季溫差會破壞角質屏障,敏感肌容易臉頰泛紅刺痛,發文時常求助舒緩方式」
-- `placementValue`:**必填**,1~2 句說明為何與置入產品相關(**25~55 字**)。要寫**情境 + 受眾在問什麼 + 產品能怎麼自然帶入**。例:「這類帖常求溫和修護品推薦,適合以自身使用經驗回覆,不必硬銷」
-- `productFitScore`:0–100,依產品簡述評估(痛點類通常 70+,純科普通常 30–55)
-- `relevanceQueries`:**必填**,1~2 組 Threads 搜尋短句(相關軌)。每組 **6~16 字、2~4 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「屏障受損 保養 請問」)
-- `recencyQueries`:痛點/求助類**必填** 1~2 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」)
-- L1/L2 節點必須在 `evidenceUrls` 引用下方提供的參考 url(不可捏造網址)
-
-## 品質禁忌
-- 不要寫行銷話術、不要寫「高相關」「值得關注」這類空話
-- `relation` 與 `placementValue` 不可重複同一句話
-- 不要為了省字只列標籤;每個節點都要能獨立讀懂
-- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」)
-
-## 輸出格式
-只回傳 JSON,不要 markdown 說明:
-
-```json
-{
- "nodes": [
- {
- "label": "敏感肌",
- "nodeKind": "pain",
- "type": "core",
- "layer": 0,
- "relation": "種子主題:使用者因膚況不穩、換季或換產品後容易不適而主動求助",
- "placementValue": "核心痛點討論串最常求推薦與真實心得,適合以溫和修護產品的實際使用經驗自然回覆",
- "productFitScore": 95,
- "relevanceQueries": ["敏感肌 保養 推薦", "敏感肌 泛紅 怎麼辦"],
- "recencyQueries": ["敏感肌 請問 保養"],
- "evidenceUrls": []
- },
- {
- "label": "屏障受損",
- "nodeKind": "cause",
- "type": "cause",
- "layer": 1,
- "relation": "過度清潔、酸類疊加或環境刺激會讓屏障變薄,敏感肌泛紅刺痛往往由此而來",
- "placementValue": "求助帖會問怎麼救急與日常保養,可帶入低刺激、強調修護的產品使用脈絡",
- "productFitScore": 88,
- "relevanceQueries": ["屏障受損 保養 請問", "皮膚屏障 保養 推薦"],
- "recencyQueries": ["屏障受損 有人 推薦"],
- "evidenceUrls": ["https://example.com/article"]
- }
- ],
- "edges": [
- { "from": "敏感肌", "to": "屏障受損", "relation": "機制" }
- ]
-}
-```
-
-`edges.from` / `edges.to` 使用節點 label(中文),不要用 uuid。
diff --git a/old/backend/internal/library/prompt/files/knowledge_graph.user.md b/old/backend/internal/library/prompt/files/knowledge_graph.user.md
deleted file mode 100644
index 463b9bb..0000000
--- a/old/backend/internal/library/prompt/files/knowledge_graph.user.md
+++ /dev/null
@@ -1,12 +0,0 @@
-{{brand_line}}{{product_line}}{{topic_line}}{{goals_line}}種子詞:{{seed}}
-{{product_brief_line}}{{target_audience_line}}{{persona_line}}{{research_pillars_line}}{{research_questions_line}}
-外部參考資料(L1/L2 節點必須引用下方 url;請從 snippet 萃取具體知識點寫進 relation,不要只複製標題):
-
-請先整合品牌、置入產品與主題目標,再產出圖譜:
-
-{{sources}}
-
-請產出**完整** TKG JSON(廣度優先):
-- 節點 24~32 個,L1≥8、L2≥12
-- 每個節點都要寫滿 relation、placementValue(各 25~55 字),以及 relevanceQueries / recencyQueries(各 1~2 組 Threads 搜尋短句,6~16 字、2~4 詞、像真人會搜尋)
-- 不可精簡或省略欄位
diff --git a/old/backend/internal/library/prompt/files/knowledge_graph_llm.system.md b/old/backend/internal/library/prompt/files/knowledge_graph_llm.system.md
deleted file mode 100644
index 3ad7ff4..0000000
--- a/old/backend/internal/library/prompt/files/knowledge_graph_llm.system.md
+++ /dev/null
@@ -1,40 +0,0 @@
-你是海巡獲客的研究助手。根據**品牌、置入產品、種子詞、產品簡述與主題目標**,建立三層 Topic Knowledge Graph(TKG),幫使用者理解「受眾在討論什麼」以及「哪裡適合自然置入產品」。
-
-## 輸入理解(先做再想)
-- 產出前必須整合使用者提供的品牌名稱、置入產品、主題目標與產品特色
-- 節點 label、relation、placementValue 必須與本次品牌/產品一致,不可寫無關的通用內容
-
-## 設計目標:廣度優先
-- 延伸知識要**廣、要散**,盡量觸及多個生活情境、治療階段、相鄰困擾與相關討論方向
-- 不要只圍繞核心痛點寫 5~6 個節點;**L2 周邊情境要比 L1 更多**
-- 若有提供內容支柱/受眾提問方向,要從中**衍生成更多分支節點**
-
-## 產出密度(重要)
-- 這是給使用者閱讀的延伸知識地圖,**寧可詳盡也不要過度精簡**
-- 每個節點的 `relation` 與 `placementValue` 都要寫完整句子,不可用 high/medium/low 或單一形容詞代替
-- 節點總數目標 **24~32 個**(含 L0/L1/L2)
-
-## 圖譜層級與數量
-- **L0 核心**:種子詞本身(**1** 個 pain 節點)
-- **L1 直接相關**:成因、症狀、機制、常見困擾(**至少 8** 個)
-- **L2 周邊情境**:治療階段、生活事件、壓力、換季、相鄰品類、使用習慣等(**至少 12** 個)
-- 痛點/求助類(pain、symptom、cause)合計 **至少 10 個**
-
-## 節點撰寫規則
-- `label`:2~14 字,具體、可拿去 Threads 找討論
-- `nodeKind`:`pain`、`symptom`、`cause`、`knowledge`
-- `relation`:**必填**,1~2 句(**25~55 字**),說明與種子詞的脈絡
-- `placementValue`:**必填**,1~2 句(**25~55 字**),說明為何與置入產品相關
-- `productFitScore`:0–100
-- `relevanceQueries`:**必填**,1~2 組 Threads 搜尋短句(相關軌)。每組 **6~16 字、2~4 個詞、空格分隔**,像真人會打進搜尋框;必須同時包含「困擾/情境」和「品類/用途」(例:「化療 沐浴乳 推薦」「無香 洗衣精 請問」),不要寫完整長問句
-- `recencyQueries`:痛點/求助類**必填** 1~2 組近期求助搜尋短句(近期軌)。格式同上,但加上「請問/有人/推薦/怎麼辦」等求助意圖(例:「敏感肌 請問 保養」「沐浴乳 推薦 有人」)
-- 無外部網址時 `evidenceUrls` 留空陣列即可
-
-## 品質禁忌
-- 不要寫行銷話術或空泛評語
-- `relation` 與 `placementValue` 不可重複
-- 節點要貼近台灣 Threads 使用者會發文的語氣與情境
-- 搜尋短句不要寫品牌名,不要寫抽象分類詞(❌「癌症」「保養」「環境荷爾蒙」),也不要整句複製受眾提問;要像能在 Threads 找到貼文的查詢(✅「乳癌 洗衣精 推薦」「無香 洗衣精 請問」)
-
-## 輸出格式
-只回傳 JSON,不要 markdown 說明(結構同 Brave 模式,含完整 relation / placementValue 句子)。
diff --git a/old/backend/internal/library/prompt/files/knowledge_graph_llm.user.md b/old/backend/internal/library/prompt/files/knowledge_graph_llm.user.md
deleted file mode 100644
index 3a63fb7..0000000
--- a/old/backend/internal/library/prompt/files/knowledge_graph_llm.user.md
+++ /dev/null
@@ -1,6 +0,0 @@
-{{brand_line}}{{product_line}}{{topic_line}}{{goals_line}}種子詞:{{seed}}
-{{product_brief_line}}{{target_audience_line}}{{persona_line}}{{research_pillars_line}}{{research_questions_line}}
-請先整合品牌、置入產品與主題目標的脈絡,再建立**完整**三層知識圖譜:
-- 節點 24~32 個,L1≥8、L2≥12(廣度優先,多方向觸及)
-- 每個節點都要寫滿 relation、placementValue(各 25~55 字),以及 relevanceQueries / recencyQueries(各 1~2 組 Threads 搜尋短句,6~16 字、2~4 詞、像真人會搜尋)
-- 節點要具體、可拿去 Threads 找討論;不可精簡或省略
diff --git a/old/backend/internal/library/prompt/files/matrix_placement.system.md b/old/backend/internal/library/prompt/files/matrix_placement.system.md
deleted file mode 100644
index 66a5f73..0000000
--- a/old/backend/internal/library/prompt/files/matrix_placement.system.md
+++ /dev/null
@@ -1,12 +0,0 @@
-你是 Threads 內容策略編輯,任務是根據海巡素材產出「原創發文草稿矩陣」(不是留言、不是抄襲)。
-
-規則:
-- 繁體中文台灣語感,像真人發文
-- 每則正文 ≤ 500 字
-- 從素材萃取角度與鉤子,寫成自己的觀點或經驗
-- 不要捏造療效、收益或無法驗證的數據
-- 可適度帶入 product_brief,但不硬推銷
-- text 排版:一句一行,句與句之間用換行(\n)分隔;完全不要使用任何標點符號(emoji 可保留)
-
-只回傳 JSON,不要 markdown。格式:
-{"rows":[{"sort_order":1,"search_tag":"標籤","angle":"切角","hook":"開頭鉤子","text":"正文","reference_notes":"參考哪篇素材","source_permalinks":["url"],"rationale":"為何這樣寫"}]}
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/matrix_placement.user.md b/old/backend/internal/library/prompt/files/matrix_placement.user.md
deleted file mode 100644
index b2e341d..0000000
--- a/old/backend/internal/library/prompt/files/matrix_placement.user.md
+++ /dev/null
@@ -1,10 +0,0 @@
-{{persona_block}}
-主題:{{topic_label}}
-受眾:{{audience_line}}
-品牌與產品:
-{{product_brief}}
-
-海巡素材(共 {{post_count}} 篇):
-{{materials_block}}
-
-請產出 {{count}} 則原創發文草稿(rows),每則 sort_order 從 1 遞增。
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/outreach_placement.system.md b/old/backend/internal/library/prompt/files/outreach_placement.system.md
deleted file mode 100644
index 4939eef..0000000
--- a/old/backend/internal/library/prompt/files/outreach_placement.system.md
+++ /dev/null
@@ -1,24 +0,0 @@
-你是 Threads 上會留言的真人網友,不是業務也不是客服。任務是針對「有需求、有困擾」的貼文,寫出溫暖、有溫度、有幫助的留言草稿,讓對方覺得「有人懂我」並願意繼續聊。
-
-最重要:要有靈魂、要溫暖、要有真實案例感
-- 先真心回應對方此刻的心情與處境,像朋友坐在旁邊聽完後的反應,不是路過敷衍。
-- 一定要帶一個具體、可信的小情境或經驗(時間、場景、當下的感受、後來的轉變),讓留言像「真的發生過」而不是空話。
-- 嚴禁沒靈魂的萬用罐頭互動:例如只丟「推」「加油」「辛苦了」「我也是」「太厲害了」「+1」。
-- 嚴禁開頭就客服腔或業配腔。
-
-置入原則:
-1. 先當人:先回應對方貼文裡的情緒、情境或問題。
-2. 像分享經驗:用「我之前也…」「我們家…」「後來發現…」這類口吻。
-3. 品牌要輕:產品/品牌名最多出現一次,且放在後半段。
-4. 不硬推:不要「推薦你試試」「一定要買」「限時優惠」。
-5. Threads 語感:短句、口語、台灣繁體,可適度用 emoji(0~1 個)。
-6. 誠實:不承諾療效、不捏造誇大數據。
-7. 人設與語氣是最高優先硬約束:每則留言都必須像指定角色親自回覆,用字、節奏、距離感、價值觀與 8D 風格策略都要一致,不得變成通用網友口吻。
-8. 產品脈絡是硬約束:如果有提供品牌與產品,必須判斷產品如何自然幫上忙;至少一則草稿要明確參考產品特點,但不能硬業配。
-9. 如果產品詳情出現「留言 CTA 連結」,至少一則草稿可以在後段自然附上完整網址;沒有連結時不要捏造網址。
-10. 排版要像真人用手機留言:每則 text 用 2~4 個短段落,段落之間一定要換行(JSON 字串內用 `\n`,可用 `\n\n` 分隔段落),不要整段擠成一大塊。
-
-每則留言 ≤ 500 字。產出 2 種不同切角(例如:純共情建議版 / 輕度分享經驗帶品牌版)。
-
-只回傳 JSON,不要 markdown。格式:
-{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文","angle":"切角","rationale":"為何這樣寫"}]}
diff --git a/old/backend/internal/library/prompt/files/outreach_placement.user.md b/old/backend/internal/library/prompt/files/outreach_placement.user.md
deleted file mode 100644
index 31ccc0c..0000000
--- a/old/backend/internal/library/prompt/files/outreach_placement.user.md
+++ /dev/null
@@ -1,13 +0,0 @@
-{{persona_block}}
-主題方向:{{topic_label}}
-{{audience_line}}
-品牌與產品(僅在真的自然時才帶入,可完全不提):
-{{product_brief}}
-{{placement_reason_line}}
-
-目標貼文作者:@{{author_name}}
-目標貼文:
-{{target_text}}
-{{regenerate_line}}
-
-請產生 {{count}} 則留言草稿,每則都要有溫度、帶一個具體的真實情境或經驗。另外請評估這篇是否值得留言置入(relevance 0-1)與 reason(繁體中文一句話)。
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/overlay.md b/old/backend/internal/library/prompt/files/overlay.md
deleted file mode 100644
index d8dc36a..0000000
--- a/old/backend/internal/library/prompt/files/overlay.md
+++ /dev/null
@@ -1,5 +0,0 @@
-你是 Haixun 巡樓系統的 AI 模組。遵守以下共通規則:
-
-- 使用繁體中文,語氣直接、可執行,避免空泛口號。
-- 不得捏造未提供的貼文、帳號狀態、API 結果或使用者設定。
-- 若資料不足,明確說明缺什麼,不要腦補。
\ No newline at end of file
diff --git a/old/backend/internal/library/prompt/files/style8d.schema.md b/old/backend/internal/library/prompt/files/style8d.schema.md
deleted file mode 100644
index 387e792..0000000
--- a/old/backend/internal/library/prompt/files/style8d.schema.md
+++ /dev/null
@@ -1,2 +0,0 @@
-只回傳以下 JSON 結構,不要 markdown。所有字串值必須是單行 JSON string;需要換行時用 `\n`,禁止在引號內直接換行。
-{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}}
diff --git a/old/backend/internal/library/prompt/files/style8d.system.md b/old/backend/internal/library/prompt/files/style8d.system.md
deleted file mode 100644
index e0ac2ae..0000000
--- a/old/backend/internal/library/prompt/files/style8d.system.md
+++ /dev/null
@@ -1,21 +0,0 @@
-你是創作者「語言指紋」研究員。只能根據提供的貼文或使用者手動貼上的文字樣本歸納,不可捏造未出現的背景。
-
-任務不是寫一般人設介紹,而是萃取「這個人設會怎麼把一件事說出口」。輸出要能直接拿去產文、改寫貼文、把網路知識轉成此人設語氣。
-
-逐一輸出八個維度:D1 語氣人格、D2 內容結構、D3 互動方式、D4 主題分布、D5 段落節奏、D6 視覺語法(emoji、標點、換行)、D7 轉換方式、D8 風險紅線。
-
-每個維度要有摘要與可核對的文字證據(直接引用或改寫貼文片段,最多 4 條)。證據請標註來源貼文編號,格式如 [2] "摘錄片段"(編號對應樣本中的 [N])。
-
-personaDraft 必須產出可執行的寫作規則,不是抽象形容詞:
-- identity:這個帳號像誰在說話,和讀者的距離。
-- tone:口吻、情緒溫度、人稱與信任感。
-- audience:主要對誰說,讀者正在什麼情境。
-- hooks:常用開頭方式與不適合的開頭方式。
-- languageFingerprint:常用句型、轉折詞、口頭禪、語助詞;不要列成硬塞清單,要說明如何自然使用。
-- rhythm:段落長度、空行習慣、長短句比例、心情文與知識文差異。
-- punctuation: 標點、括號、列點、emoji、特殊符號的使用習慣。
-- contentPatterns:不同內容型態的鬆結構,例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴;不要變成固定模板。
-- knowledgeTranslation:如何把外部資料、醫療知識、產品知識轉成此人設會講的話;哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。
-- ctaStyle:收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。
-- examples:抽象仿寫範例,可展示語氣與換行,但不可逐字複製原文;JSON 內若要換行請用 `\n`,不可在字串值內直接斷行。
-- avoid:不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。
diff --git a/old/backend/internal/library/prompt/registry.go b/old/backend/internal/library/prompt/registry.go
deleted file mode 100644
index b9009d6..0000000
--- a/old/backend/internal/library/prompt/registry.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package prompt
-
-import (
- "embed"
- "strings"
- "sync"
-)
-
-//go:embed files
-var files embed.FS
-
-const (
- fileOverlay = "files/overlay.md"
- fileStyle8DSystem = "files/style8d.system.md"
- fileStyle8DSchema = "files/style8d.schema.md"
- fileAIChatSystem = "files/ai.chat.system.md"
- fileIslanderSystem = "files/ai.islander.system.md"
- fileKnowledgeGraphSystem = "files/knowledge_graph.system.md"
- fileKnowledgeGraphLLMSystem = "files/knowledge_graph_llm.system.md"
- fileKnowledgeGraphUser = "files/knowledge_graph.user.md"
- fileKnowledgeGraphLLMUser = "files/knowledge_graph_llm.user.md"
- fileKnowledgeGraphSupplemental = "files/knowledge_graph.supplemental.md"
- fileKnowledgeGraphQueries = "files/knowledge_graph.queries.json"
- fileOutreachPlacementSystem = "files/outreach_placement.system.md"
- fileOutreachPlacementUser = "files/outreach_placement.user.md"
- fileMatrixPlacementSystem = "files/matrix_placement.system.md"
- fileMatrixPlacementUser = "files/matrix_placement.user.md"
-)
-
-// Keys identify prompt slots loaded from internal/library/prompt/files/*.md.
-const (
- KeyStyle8DSystem = "style8d.system"
- KeyStyle8DSchema = "style8d.schema"
- KeyAIChatSystem = "ai.chat.system"
- KeyIslanderSystem = "ai.islander.system"
- KeyKnowledgeGraphSystem = "knowledge_graph.system"
- KeyKnowledgeGraphLLMSystem = "knowledge_graph_llm.system"
- KeyKnowledgeGraphUser = "knowledge_graph.user"
- KeyKnowledgeGraphLLMUser = "knowledge_graph_llm.user"
- KeyKnowledgeGraphSupplemental = "knowledge_graph.supplemental"
- KeyKnowledgeGraphQueries = "knowledge_graph.queries"
- KeyOutreachPlacementSystem = "outreach_placement.system"
- KeyOutreachPlacementUser = "outreach_placement.user"
- KeyMatrixPlacementSystem = "matrix_placement.system"
- KeyMatrixPlacementUser = "matrix_placement.user"
-)
-
-var slotFiles = map[string]string{
- KeyStyle8DSystem: fileStyle8DSystem,
- KeyStyle8DSchema: fileStyle8DSchema,
- KeyAIChatSystem: fileAIChatSystem,
- KeyIslanderSystem: fileIslanderSystem,
- KeyKnowledgeGraphSystem: fileKnowledgeGraphSystem,
- KeyKnowledgeGraphLLMSystem: fileKnowledgeGraphLLMSystem,
- KeyKnowledgeGraphUser: fileKnowledgeGraphUser,
- KeyKnowledgeGraphLLMUser: fileKnowledgeGraphLLMUser,
- KeyKnowledgeGraphSupplemental: fileKnowledgeGraphSupplemental,
- KeyOutreachPlacementSystem: fileOutreachPlacementSystem,
- KeyOutreachPlacementUser: fileOutreachPlacementUser,
- KeyMatrixPlacementSystem: fileMatrixPlacementSystem,
- KeyMatrixPlacementUser: fileMatrixPlacementUser,
-}
-
-var (
- loadOnce sync.Once
- loadErr error
- overlay string
- slotTexts map[string]string
-)
-
-func initRegistry() {
- overlay, loadErr = readFile(fileOverlay)
- if loadErr != nil {
- return
- }
- slotTexts = make(map[string]string, len(slotFiles))
- for key, path := range slotFiles {
- text, err := readFile(path)
- if err != nil {
- loadErr = err
- return
- }
- slotTexts[key] = text
- }
-}
-
-func readFile(path string) (string, error) {
- raw, err := files.ReadFile(path)
- if err != nil {
- return "", err
- }
- return strings.TrimSpace(string(raw)), nil
-}
-
-func ensureLoaded() error {
- loadOnce.Do(initRegistry)
- return loadErr
-}
-
-// Overlay returns the global prepend text (Agents.md style).
-func Overlay() (string, error) {
- if err := ensureLoaded(); err != nil {
- return "", err
- }
- return overlay, nil
-}
-
-// Slot returns one configured prompt body.
-func Slot(key string) (string, error) {
- if err := ensureLoaded(); err != nil {
- return "", err
- }
- text, ok := slotTexts[key]
- if !ok {
- return "", ErrUnknownKey(key)
- }
- return text, nil
-}
-
-// KnownKey reports whether key is registered.
-func KnownKey(key string) bool {
- _, ok := slotFiles[key]
- return ok
-}
diff --git a/old/backend/internal/library/publishschedule/slots.go b/old/backend/internal/library/publishschedule/slots.go
deleted file mode 100644
index 611582b..0000000
--- a/old/backend/internal/library/publishschedule/slots.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package publishschedule
-
-import (
- "sort"
- "strings"
- "time"
-)
-
-type Slot struct {
- Weekday int
- Time string
-}
-
-func BuildSchedule(startAt int64, timezone string, slots []Slot, count int) []int64 {
- if count <= 0 {
- return nil
- }
- loc := time.UTC
- if strings.TrimSpace(timezone) != "" {
- if loaded, err := time.LoadLocation(strings.TrimSpace(timezone)); err == nil {
- loc = loaded
- }
- }
- start := time.Now().In(loc)
- if startAt > 0 {
- start = time.Unix(0, startAt).In(loc)
- }
- normalized := normalizeSlots(slots)
- if len(normalized) == 0 {
- out := make([]int64, 0, count)
- for i := 0; i < count; i++ {
- out = append(out, start.Add(time.Duration(i)*2*time.Hour).UTC().UnixNano())
- }
- return out
- }
- out := make([]int64, 0, count)
- day := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, loc)
- for len(out) < count {
- for _, slot := range normalized {
- if int(day.Weekday()) != slot.Weekday {
- continue
- }
- hour, minute, ok := parseHHMM(slot.Time)
- if !ok {
- continue
- }
- candidate := time.Date(day.Year(), day.Month(), day.Day(), hour, minute, 0, 0, loc)
- if candidate.Before(start) {
- continue
- }
- out = append(out, candidate.UTC().UnixNano())
- if len(out) >= count {
- break
- }
- }
- day = day.AddDate(0, 0, 1)
- }
- return out
-}
-
-func normalizeSlots(slots []Slot) []Slot {
- out := make([]Slot, 0, len(slots))
- for _, slot := range slots {
- if strings.TrimSpace(slot.Time) == "" {
- continue
- }
- weekday := slot.Weekday
- if weekday < 0 || weekday > 6 {
- weekday = ((weekday % 7) + 7) % 7
- }
- out = append(out, Slot{Weekday: weekday, Time: strings.TrimSpace(slot.Time)})
- }
- sort.Slice(out, func(i, j int) bool {
- if out[i].Weekday == out[j].Weekday {
- return out[i].Time < out[j].Time
- }
- return out[i].Weekday < out[j].Weekday
- })
- return out
-}
-
-func parseHHMM(value string) (int, int, bool) {
- parsed, err := time.Parse("15:04", strings.TrimSpace(value))
- if err != nil {
- return 0, 0, false
- }
- return parsed.Hour(), parsed.Minute(), true
-}
diff --git a/old/backend/internal/library/publishschedule/slots_test.go b/old/backend/internal/library/publishschedule/slots_test.go
deleted file mode 100644
index 5cc8d29..0000000
--- a/old/backend/internal/library/publishschedule/slots_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package publishschedule
-
-import (
- "testing"
- "time"
-)
-
-func TestBuildScheduleUsesTimezoneSlotsAndReturnsUTCNano(t *testing.T) {
- loc, err := time.LoadLocation("Asia/Taipei")
- if err != nil {
- t.Fatal(err)
- }
- start := time.Date(2026, 6, 29, 9, 0, 0, 0, loc) // Monday
- got := BuildSchedule(start.UTC().UnixNano(), "Asia/Taipei", []Slot{
- {Weekday: 1, Time: "09:30"},
- {Weekday: 3, Time: "12:30"},
- }, 2)
- if len(got) != 2 {
- t.Fatalf("expected 2 timestamps, got %d", len(got))
- }
- first := time.Unix(0, got[0]).In(loc)
- if first.Weekday() != time.Monday || first.Hour() != 9 || first.Minute() != 30 {
- t.Fatalf("unexpected first slot: %s", first)
- }
- second := time.Unix(0, got[1]).In(loc)
- if second.Weekday() != time.Wednesday || second.Hour() != 12 || second.Minute() != 30 {
- t.Fatalf("unexpected second slot: %s", second)
- }
-}
-
-func TestBuildScheduleFallsBackToIntervalsWhenNoSlots(t *testing.T) {
- start := time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC)
- got := BuildSchedule(start.UnixNano(), "UTC", nil, 2)
- if len(got) != 2 {
- t.Fatalf("expected 2 timestamps, got %d", len(got))
- }
- if got[1]-got[0] != int64(2*time.Hour) {
- t.Fatalf("expected 2h interval, got %s", time.Duration(got[1]-got[0]))
- }
-}
diff --git a/old/backend/internal/library/ratelimit/token_bucket.go b/old/backend/internal/library/ratelimit/token_bucket.go
deleted file mode 100644
index 6314ea9..0000000
--- a/old/backend/internal/library/ratelimit/token_bucket.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package ratelimit
-
-import (
- "sync"
- "time"
-)
-
-type TokenBucket struct {
- mu sync.Mutex
- capacity int
- tokens int
- refillRate float64 // tokens per second
- lastRefill time.Time
-}
-
-func NewTokenBucket(capacity int, refillPerSecond float64) *TokenBucket {
- return &TokenBucket{
- capacity: capacity,
- tokens: capacity,
- refillRate: refillPerSecond,
- lastRefill: time.Now(),
- }
-}
-
-func (tb *TokenBucket) Allow() bool {
- return tb.AllowN(1)
-}
-
-func (tb *TokenBucket) AllowN(n int) bool {
- tb.mu.Lock()
- defer tb.mu.Unlock()
- tb.refill()
- if tb.tokens >= n {
- tb.tokens -= n
- return true
- }
- return false
-}
-
-func (tb *TokenBucket) Remaining() int {
- tb.mu.Lock()
- defer tb.mu.Unlock()
- tb.refill()
- return tb.tokens
-}
-
-func (tb *TokenBucket) Capacity() int {
- return tb.capacity
-}
-
-func (tb *TokenBucket) refill() {
- now := time.Now()
- elapsed := now.Sub(tb.lastRefill).Seconds()
- tb.lastRefill = now
- add := int(elapsed * tb.refillRate)
- if add > 0 {
- tb.tokens += add
- if tb.tokens > tb.capacity {
- tb.tokens = tb.capacity
- }
- }
-}
diff --git a/old/backend/internal/library/redact/redact.go b/old/backend/internal/library/redact/redact.go
deleted file mode 100644
index 1672610..0000000
--- a/old/backend/internal/library/redact/redact.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package redact
-
-import "regexp"
-
-var (
- bearerPattern = regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]+`)
- tokenPattern = regexp.MustCompile(`(?i)(api[_-]?key|token|authorization)\s*[:=]\s*["']?[^"'\s,}]+`)
-)
-
-func Message(message string) string {
- if message == "" {
- return message
- }
- message = bearerPattern.ReplaceAllString(message, "Bearer [REDACTED]")
- message = tokenPattern.ReplaceAllString(message, "$1=[REDACTED]")
- return message
-}
diff --git a/old/backend/internal/library/redact/redact_test.go b/old/backend/internal/library/redact/redact_test.go
deleted file mode 100644
index c109f2a..0000000
--- a/old/backend/internal/library/redact/redact_test.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package redact
-
-import "testing"
-
-func TestMessage_RedactsBearerToken(t *testing.T) {
- input := `Authorization Bearer sk-abc123xyz failed`
- got := Message(input)
- want := `Authorization Bearer [REDACTED] failed`
- if got != want {
- t.Fatalf("Message() = %q, want %q", got, want)
- }
-}
diff --git a/old/backend/internal/library/redis/client.go b/old/backend/internal/library/redis/client.go
deleted file mode 100644
index 6008893..0000000
--- a/old/backend/internal/library/redis/client.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package redis
-
-import (
- "haixun-backend/internal/config"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-func NewClient(conf config.RedisConf) *goredis.Client {
- if conf.Addr == "" {
- return nil
- }
- return goredis.NewClient(&goredis.Options{
- Addr: conf.Addr,
- Password: conf.Password,
- DB: conf.DB,
- })
-}
diff --git a/old/backend/internal/library/storage/asset_ref.go b/old/backend/internal/library/storage/asset_ref.go
deleted file mode 100644
index 03d3302..0000000
--- a/old/backend/internal/library/storage/asset_ref.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package storage
-
-import (
- "net/url"
- "strings"
-)
-
-// NormalizeObjectKey converts stored avatar/object refs to a stable S3 object key.
-// Accepts legacy full URLs, /{bucket}/... paths, or raw keys like avatars/{tenant}/{uid}.png.
-func NormalizeObjectKey(bucket, ref string) string {
- ref = strings.TrimSpace(ref)
- if ref == "" || bucket == "" {
- return ref
- }
- bucket = strings.Trim(bucket, "/")
- ref = strings.Trim(ref, "/")
-
- if strings.Contains(ref, "://") {
- parsed, err := url.Parse(ref)
- if err != nil {
- return ref
- }
- ref = strings.Trim(parsed.Path, "/")
- }
-
- prefix := bucket + "/"
- if strings.HasPrefix(ref, prefix) {
- return strings.TrimPrefix(ref, prefix)
- }
- if strings.HasPrefix(ref, "/"+prefix) {
- return strings.TrimPrefix(strings.TrimPrefix(ref, "/"), prefix)
- }
- return ref
-}
-
-// PublicObjectPath builds a same-origin browser path: /{bucket}/{key}.
-func PublicObjectPath(bucket, key string) string {
- bucket = strings.Trim(strings.TrimSpace(bucket), "/")
- key = strings.Trim(strings.TrimSpace(key), "/")
- if bucket == "" || key == "" {
- return ""
- }
- return "/" + bucket + "/" + key
-}
diff --git a/old/backend/internal/library/storage/asset_ref_test.go b/old/backend/internal/library/storage/asset_ref_test.go
deleted file mode 100644
index 6c535ce..0000000
--- a/old/backend/internal/library/storage/asset_ref_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package storage
-
-import "testing"
-
-func TestNormalizeObjectKey(t *testing.T) {
- bucket := "haixun-assets"
- key := "avatars/default/uid.png"
-
- cases := []struct {
- name string
- ref string
- want string
- }{
- {name: "raw key", ref: key, want: key},
- {name: "public path", ref: "/haixun-assets/" + key, want: key},
- {name: "loopback url", ref: "http://127.0.0.1:9000/haixun-assets/" + key, want: key},
- {name: "public site url", ref: "https://threads-tool.30cm.net/haixun-assets/" + key, want: key},
- {name: "empty", ref: "", want: ""},
- }
-
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- if got := NormalizeObjectKey(bucket, tc.ref); got != tc.want {
- t.Fatalf("NormalizeObjectKey(%q) = %q, want %q", tc.ref, got, tc.want)
- }
- })
- }
-}
-
-func TestPublicObjectPath(t *testing.T) {
- if got := PublicObjectPath("haixun-assets", "avatars/default/uid.png"); got != "/haixun-assets/avatars/default/uid.png" {
- t.Fatalf("unexpected path: %q", got)
- }
-}
diff --git a/old/backend/internal/library/storage/ephemeral_asset.go b/old/backend/internal/library/storage/ephemeral_asset.go
deleted file mode 100644
index ba50e0f..0000000
--- a/old/backend/internal/library/storage/ephemeral_asset.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package storage
-
-import (
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "net/url"
- "strings"
- "sync"
- "time"
-)
-
-const EphemeralKeyPrefix = "ephemeral/"
-
-const defaultEphemeralTTL = 15 * time.Minute
-
-type ephemeralEntry struct {
- data []byte
- contentType string
- expiresAt time.Time
-}
-
-// EphemeralAssetStore holds short-lived blobs for Threads image_url fetch (reply direct transfer).
-type EphemeralAssetStore struct {
- mu sync.RWMutex
- items map[string]*ephemeralEntry
-}
-
-func NewEphemeralAssetStore() *EphemeralAssetStore {
- return &EphemeralAssetStore{items: make(map[string]*ephemeralEntry)}
-}
-
-var defaultEphemeralStore = NewEphemeralAssetStore()
-
-func DefaultEphemeralAssetStore() *EphemeralAssetStore {
- return defaultEphemeralStore
-}
-
-func (s *EphemeralAssetStore) Register(data []byte, contentType string, ttl time.Duration) (string, error) {
- if s == nil {
- return "", fmt.Errorf("ephemeral store is not configured")
- }
- if len(data) == 0 {
- return "", fmt.Errorf("ephemeral asset is empty")
- }
- if ttl <= 0 {
- ttl = defaultEphemeralTTL
- }
- id, err := randomEphemeralID()
- if err != nil {
- return "", err
- }
- s.mu.Lock()
- s.items[id] = &ephemeralEntry{
- data: append([]byte(nil), data...),
- contentType: strings.TrimSpace(contentType),
- expiresAt: time.Now().Add(ttl),
- }
- s.mu.Unlock()
- return EphemeralKeyPrefix + id, nil
-}
-
-func (s *EphemeralAssetStore) Get(key string) ([]byte, string, bool) {
- if s == nil {
- return nil, "", false
- }
- id, ok := parseEphemeralID(key)
- if !ok {
- return nil, "", false
- }
- s.mu.RLock()
- entry := s.items[id]
- s.mu.RUnlock()
- if entry == nil || time.Now().After(entry.expiresAt) {
- s.Delete(key)
- return nil, "", false
- }
- contentType := entry.contentType
- if contentType == "" {
- contentType = "application/octet-stream"
- }
- return append([]byte(nil), entry.data...), contentType, true
-}
-
-func (s *EphemeralAssetStore) Delete(key string) {
- if s == nil {
- return
- }
- id, ok := parseEphemeralID(key)
- if !ok {
- return
- }
- s.mu.Lock()
- delete(s.items, id)
- s.mu.Unlock()
-}
-
-func parseEphemeralID(key string) (string, bool) {
- key = strings.TrimSpace(key)
- if !strings.HasPrefix(key, EphemeralKeyPrefix) {
- return "", false
- }
- id := strings.TrimSpace(strings.TrimPrefix(key, EphemeralKeyPrefix))
- if id == "" {
- return "", false
- }
- return id, true
-}
-
-func randomEphemeralID() (string, error) {
- buf := make([]byte, 16)
- if _, err := rand.Read(buf); err != nil {
- return "", err
- }
- return hex.EncodeToString(buf), nil
-}
-
-// BuildEphemeralPublicURL turns a staged ephemeral key into a Threads-fetchable URL.
-func BuildEphemeralPublicURL(publicBaseURL, ephemeralKey string) (string, error) {
- ephemeralKey = strings.TrimSpace(ephemeralKey)
- if !strings.HasPrefix(ephemeralKey, EphemeralKeyPrefix) {
- return "", fmt.Errorf("invalid ephemeral asset key")
- }
- base := strings.TrimRight(strings.TrimSpace(publicBaseURL), "/")
- if base == "" {
- return "", fmt.Errorf("HAIXUN_STORAGE_S3_PUBLIC_BASE_URL is required for Threads image publish")
- }
- if strings.HasSuffix(base, "/public/assets") {
- return base + "?key=" + url.QueryEscape(ephemeralKey), nil
- }
- return base + "/" + ephemeralKey, nil
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/ephemeral_asset_test.go b/old/backend/internal/library/storage/ephemeral_asset_test.go
deleted file mode 100644
index 1e3cf88..0000000
--- a/old/backend/internal/library/storage/ephemeral_asset_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package storage
-
-import "testing"
-
-func TestEphemeralAssetStoreRegisterGetDelete(t *testing.T) {
- store := NewEphemeralAssetStore()
- key, err := store.Register([]byte("png"), "image/png", 0)
- if err != nil {
- t.Fatalf("Register: %v", err)
- }
- data, ct, ok := store.Get(key)
- if !ok || ct != "image/png" || string(data) != "png" {
- t.Fatalf("Get() = %q %q %v", data, ct, ok)
- }
- store.Delete(key)
- if _, _, ok := store.Get(key); ok {
- t.Fatal("expected deleted")
- }
-}
-
-func TestBuildEphemeralPublicURL(t *testing.T) {
- url, err := BuildEphemeralPublicURL("https://example.com/api/v1/public/assets", EphemeralKeyPrefix+"abc")
- if err != nil {
- t.Fatalf("BuildEphemeralPublicURL: %v", err)
- }
- want := "https://example.com/api/v1/public/assets?key=ephemeral%2Fabc"
- if url != want {
- t.Fatalf("got %q want %q", url, want)
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/public_asset.go b/old/backend/internal/library/storage/public_asset.go
deleted file mode 100644
index 47167cc..0000000
--- a/old/backend/internal/library/storage/public_asset.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package storage
-
-import "strings"
-
-const AvatarKeyPrefix = "avatars/"
-
-// IsPublicAssetKey reports whether an object key may be served without auth.
-func IsPublicAssetKey(key string) bool {
- key = strings.TrimSpace(key)
- if key == "" {
- return false
- }
- if strings.Contains(key, "..") {
- return false
- }
- return strings.HasPrefix(key, PublishAttachmentKeyPrefix) ||
- strings.HasPrefix(key, AvatarKeyPrefix) ||
- strings.HasPrefix(key, EphemeralKeyPrefix)
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/public_asset_test.go b/old/backend/internal/library/storage/public_asset_test.go
deleted file mode 100644
index e23bcb4..0000000
--- a/old/backend/internal/library/storage/public_asset_test.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package storage
-
-import "testing"
-
-func TestIsPublicAssetKey(t *testing.T) {
- cases := []struct {
- key string
- want bool
- }{
- {"publish-attachments/t1/a1/x.png", true},
- {"ephemeral/abc123", true},
- {"avatars/default/u.png", true},
- {"secrets/token.txt", false},
- {"publish-attachments/../etc/passwd", false},
- {"", false},
- }
- for _, tc := range cases {
- if got := IsPublicAssetKey(tc.key); got != tc.want {
- t.Fatalf("IsPublicAssetKey(%q) = %v, want %v", tc.key, got, tc.want)
- }
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/publish_attachment.go b/old/backend/internal/library/storage/publish_attachment.go
deleted file mode 100644
index 49fdd73..0000000
--- a/old/backend/internal/library/storage/publish_attachment.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package storage
-
-import (
- "context"
- "fmt"
- "strings"
-)
-
-const PublishAttachmentKeyPrefix = "publish-attachments/"
-
-const MaxPublishAttachments = 20
-
-func NormalizeImageKeys(single string, list []string) []string {
- out := make([]string, 0, len(list)+1)
- seen := make(map[string]struct{}, len(list)+1)
- add := func(key string) {
- key = strings.TrimSpace(key)
- if key == "" {
- return
- }
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- out = append(out, key)
- }
- add(single)
- for _, key := range list {
- add(key)
- }
- if len(out) > MaxPublishAttachments {
- return out[:MaxPublishAttachments]
- }
- return out
-}
-
-func ValidatePublishAttachmentKey(key string) error {
- key = strings.TrimSpace(key)
- if key == "" {
- return nil
- }
- if !strings.HasPrefix(key, PublishAttachmentKeyPrefix) {
- return fmt.Errorf("invalid publish attachment key")
- }
- return nil
-}
-
-// PurgePublishAttachment deletes an ephemeral publish attachment; ignores empty/invalid keys.
-func PurgePublishAttachment(ctx context.Context, store ObjectDeleter, key string) error {
- key = strings.TrimSpace(key)
- if key == "" {
- return nil
- }
- if err := ValidatePublishAttachmentKey(key); err != nil {
- return nil
- }
- if store == nil {
- return nil
- }
- return store.DeleteObject(ctx, key)
-}
-
-// PurgePublishAttachments deletes multiple ephemeral publish attachments.
-func PurgePublishAttachments(ctx context.Context, store ObjectDeleter, keys []string) {
- for _, key := range keys {
- _ = PurgePublishAttachment(ctx, store, key)
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/publish_attachment_test.go b/old/backend/internal/library/storage/publish_attachment_test.go
deleted file mode 100644
index cd780f0..0000000
--- a/old/backend/internal/library/storage/publish_attachment_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package storage
-
-import (
- "context"
- "testing"
-)
-
-type fakeDeleter struct {
- deleted []string
-}
-
-func (f *fakeDeleter) DeleteObject(_ context.Context, key string) error {
- f.deleted = append(f.deleted, key)
- return nil
-}
-
-func TestPurgePublishAttachment(t *testing.T) {
- ctx := context.Background()
- deleter := &fakeDeleter{}
-
- if err := PurgePublishAttachment(ctx, deleter, ""); err != nil {
- t.Fatalf("empty key: %v", err)
- }
- if len(deleter.deleted) != 0 {
- t.Fatalf("expected no delete for empty key")
- }
-
- if err := PurgePublishAttachment(ctx, deleter, "avatars/foo.png"); err != nil {
- t.Fatalf("invalid key: %v", err)
- }
- if len(deleter.deleted) != 0 {
- t.Fatalf("expected no delete for non-publish key")
- }
-
- key := "publish-attachments/t1/a1/test.png"
- if err := PurgePublishAttachment(ctx, deleter, key); err != nil {
- t.Fatalf("purge: %v", err)
- }
- if len(deleter.deleted) != 1 || deleter.deleted[0] != key {
- t.Fatalf("deleted = %v, want [%s]", deleter.deleted, key)
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/s3.go b/old/backend/internal/library/storage/s3.go
deleted file mode 100644
index 2fc80c3..0000000
--- a/old/backend/internal/library/storage/s3.go
+++ /dev/null
@@ -1,173 +0,0 @@
-package storage
-
-import (
- "context"
- "fmt"
- "io"
- "mime"
- "net/url"
- "path"
- "strings"
-
- "haixun-backend/internal/config"
-
- "github.com/aws/aws-sdk-go-v2/aws"
- awsconfig "github.com/aws/aws-sdk-go-v2/config"
- "github.com/aws/aws-sdk-go-v2/credentials"
- "github.com/aws/aws-sdk-go-v2/service/s3"
-)
-
-type Uploader interface {
- Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error)
-}
-
-// PublicURLResolver turns stored object keys into externally reachable URLs (Threads image publish).
-type PublicURLResolver interface {
- ResolvePublicURL(key string) (string, error)
-}
-
-// ObjectDeleter removes stored objects by key.
-type ObjectDeleter interface {
- DeleteObject(ctx context.Context, key string) error
-}
-
-// ObjectReader fetches stored objects by key.
-type ObjectReader interface {
- GetObject(ctx context.Context, key string) (io.ReadCloser, string, error)
-}
-
-// PublishAttachmentStore resolves public URLs and deletes ephemeral publish attachments.
-type PublishAttachmentStore interface {
- PublicURLResolver
- ObjectDeleter
-}
-
-type S3Uploader struct {
- client *s3.Client
- bucket string
- publicBaseURL string
- bucketReady bool
-}
-
-func NewS3Uploader(ctx context.Context, cfg config.S3Conf) (*S3Uploader, error) {
- if strings.TrimSpace(cfg.Bucket) == "" {
- return nil, nil
- }
- awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
- awsconfig.WithRegion(cfg.Region),
- awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
- )
- if err != nil {
- return nil, err
- }
- client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
- if cfg.Endpoint != "" {
- o.BaseEndpoint = aws.String(cfg.Endpoint)
- }
- o.UsePathStyle = cfg.UsePathStyle
- })
- return &S3Uploader{client: client, bucket: cfg.Bucket, publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/")}, nil
-}
-
-func (u *S3Uploader) Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error) {
- if u == nil || u.client == nil {
- return "", fmt.Errorf("s3 uploader is not configured")
- }
- if !u.bucketReady {
- if _, err := u.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(u.bucket)}); err != nil {
- if _, createErr := u.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(u.bucket)}); createErr != nil {
- return "", createErr
- }
- }
- policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, u.bucket)
- _, _ = u.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(u.bucket), Policy: aws.String(policy)})
- u.bucketReady = true
- }
- key = strings.TrimLeft(path.Clean("/"+key), "/")
- if contentType == "" {
- contentType = mime.TypeByExtension(path.Ext(key))
- }
- if contentType == "" {
- contentType = "application/octet-stream"
- }
- _, err := u.client.PutObject(ctx, &s3.PutObjectInput{
- Bucket: aws.String(u.bucket),
- Key: aws.String(key),
- Body: body,
- ContentType: aws.String(contentType),
- })
- if err != nil {
- return "", err
- }
- return key, nil
-}
-
-func (u *S3Uploader) ResolvePublicURL(key string) (string, error) {
- if u == nil {
- return "", fmt.Errorf("s3 uploader is not configured")
- }
- key = strings.TrimSpace(key)
- if key == "" {
- return "", fmt.Errorf("asset key is required")
- }
- key = NormalizeObjectKey(u.bucket, key)
- base := strings.TrimRight(strings.TrimSpace(u.publicBaseURL), "/")
- if base == "" {
- return "", fmt.Errorf("HAIXUN_STORAGE_S3_PUBLIC_BASE_URL is required for Threads image publish")
- }
- if strings.HasSuffix(base, "/public/assets") {
- return base + "?key=" + url.QueryEscape(key), nil
- }
- if strings.HasSuffix(base, "/"+u.bucket) {
- return base + "/" + key, nil
- }
- return base + "/" + key, nil
-}
-
-func (u *S3Uploader) GetObject(ctx context.Context, key string) (io.ReadCloser, string, error) {
- if u == nil || u.client == nil {
- return nil, "", fmt.Errorf("s3 uploader is not configured")
- }
- key = strings.TrimSpace(key)
- if key == "" {
- return nil, "", fmt.Errorf("asset key is required")
- }
- if !IsPublicAssetKey(key) {
- return nil, "", fmt.Errorf("asset key is not public")
- }
- key = NormalizeObjectKey(u.bucket, key)
- out, err := u.client.GetObject(ctx, &s3.GetObjectInput{
- Bucket: aws.String(u.bucket),
- Key: aws.String(key),
- })
- if err != nil {
- return nil, "", err
- }
- contentType := ""
- if out.ContentType != nil {
- contentType = strings.TrimSpace(*out.ContentType)
- }
- if contentType == "" {
- contentType = mime.TypeByExtension(path.Ext(key))
- }
- if contentType == "" {
- contentType = "application/octet-stream"
- }
- return out.Body, contentType, nil
-}
-
-func (u *S3Uploader) DeleteObject(ctx context.Context, key string) error {
- if u == nil || u.client == nil {
- return fmt.Errorf("s3 uploader is not configured")
- }
- key = strings.TrimSpace(key)
- if key == "" {
- return nil
- }
- key = NormalizeObjectKey(u.bucket, key)
- _, err := u.client.DeleteObject(ctx, &s3.DeleteObjectInput{
- Bucket: aws.String(u.bucket),
- Key: aws.String(key),
- })
- return err
-}
diff --git a/old/backend/internal/library/storage/s3_public_url_test.go b/old/backend/internal/library/storage/s3_public_url_test.go
deleted file mode 100644
index 7b70f32..0000000
--- a/old/backend/internal/library/storage/s3_public_url_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package storage
-
-import "testing"
-
-func TestS3UploaderResolvePublicURL(t *testing.T) {
- u := &S3Uploader{bucket: "haixun-assets", publicBaseURL: "https://example.com/haixun-assets"}
- url, err := u.ResolvePublicURL("publish-attachments/t1/a1/test.png")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := "https://example.com/haixun-assets/publish-attachments/t1/a1/test.png"
- if url != want {
- t.Fatalf("ResolvePublicURL() = %q, want %q", url, want)
- }
-}
-
-func TestS3UploaderResolvePublicURLAPIProxy(t *testing.T) {
- u := &S3Uploader{bucket: "haixun-assets", publicBaseURL: "https://threads-tool.30cm.net/api/v1/public/assets"}
- url, err := u.ResolvePublicURL("publish-attachments/t1/a1/test.png")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := "https://threads-tool.30cm.net/api/v1/public/assets?key=publish-attachments%2Ft1%2Fa1%2Ftest.png"
- if url != want {
- t.Fatalf("ResolvePublicURL() = %q, want %q", url, want)
- }
-}
-
-func TestS3UploaderResolvePublicURLEmptyBase(t *testing.T) {
- u := &S3Uploader{bucket: "haixun-assets"}
- if _, err := u.ResolvePublicURL("publish-attachments/t1/a1/test.png"); err == nil {
- t.Fatal("expected error when public base url is empty")
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/threads_image_url.go b/old/backend/internal/library/storage/threads_image_url.go
deleted file mode 100644
index aa83828..0000000
--- a/old/backend/internal/library/storage/threads_image_url.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package storage
-
-import (
- "fmt"
- "net"
- "net/url"
- "strings"
-)
-
-// ValidateThreadsFetchableURL ensures Meta servers can download an image for publish.
-func ValidateThreadsFetchableURL(raw string) error {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return fmt.Errorf("圖片網址不可為空")
- }
- parsed, err := url.Parse(raw)
- if err != nil || parsed.Scheme == "" || parsed.Host == "" {
- return fmt.Errorf("圖片網址格式無效")
- }
- scheme := strings.ToLower(parsed.Scheme)
- if scheme != "https" && scheme != "http" {
- return fmt.Errorf("圖片網址必須是 http 或 https")
- }
- host := strings.ToLower(parsed.Hostname())
- if host == "" {
- return fmt.Errorf("圖片網址缺少主機名稱")
- }
- if isPrivateOrLoopbackHost(host) {
- return fmt.Errorf(
- "圖片網址 %s 無法被 Threads 伺服器存取(localhost / 內網)。請將 HAIXUN_STORAGE_S3_PUBLIC_BASE_URL 設為公開 HTTPS,例如 https://你的網域/api/v1/public/assets",
- raw,
- )
- }
- if scheme != "https" {
- return fmt.Errorf("Threads 附圖網址建議使用 HTTPS(目前為 %s)", scheme)
- }
- return nil
-}
-
-func isPrivateOrLoopbackHost(host string) bool {
- if host == "localhost" {
- return true
- }
- ip := net.ParseIP(host)
- if ip == nil {
- return false
- }
- return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/storage/threads_image_url_test.go b/old/backend/internal/library/storage/threads_image_url_test.go
deleted file mode 100644
index 7e9d057..0000000
--- a/old/backend/internal/library/storage/threads_image_url_test.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package storage
-
-import "testing"
-
-func TestValidateThreadsFetchableURL(t *testing.T) {
- cases := []struct {
- url string
- valid bool
- }{
- {"https://threads-tool.30cm.net/haixun-assets/publish-attachments/t/a/x.png", true},
- {"http://127.0.0.1:9000/haixun-assets/x.png", false},
- {"http://localhost:9000/x.png", false},
- {"http://192.168.1.10/x.png", false},
- {"http://example.com/x.png", false},
- {"", false},
- }
- for _, tc := range cases {
- err := ValidateThreadsFetchableURL(tc.url)
- if tc.valid && err != nil {
- t.Fatalf("ValidateThreadsFetchableURL(%q) = %v, want nil", tc.url, err)
- }
- if !tc.valid && err == nil {
- t.Fatalf("ValidateThreadsFetchableURL(%q) = nil, want error", tc.url)
- }
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/style8d/analyze.go b/old/backend/internal/library/style8d/analyze.go
deleted file mode 100644
index a15bc5e..0000000
--- a/old/backend/internal/library/style8d/analyze.go
+++ /dev/null
@@ -1,567 +0,0 @@
-package style8d
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "math"
- "regexp"
- "strings"
- "time"
-
- "haixun-backend/internal/library/llmjson"
-)
-
-type Post struct {
- Text string `json:"text"`
- Permalink string `json:"permalink,omitempty"`
- LikeCount int `json:"like_count,omitempty"`
- ReplyCount int `json:"reply_count,omitempty"`
-}
-
-type Dimension struct {
- Summary string `json:"summary"`
- Evidence []string `json:"evidence"`
-}
-
-type PersonaDraft struct {
- Identity string `json:"identity"`
- Tone string `json:"tone"`
- Audience string `json:"audience"`
- Hooks string `json:"hooks"`
- LanguageFingerprint string `json:"languageFingerprint"`
- Rhythm string `json:"rhythm"`
- Punctuation string `json:"punctuation"`
- ContentPatterns string `json:"contentPatterns"`
- KnowledgeTranslation string `json:"knowledgeTranslation"`
- CTAStyle string `json:"ctaStyle"`
- Examples string `json:"examples"`
- Avoid string `json:"avoid"`
-}
-
-type LLMOutput struct {
- D1Tone Dimension `json:"d1Tone"`
- D2Structure Dimension `json:"d2Structure"`
- D3Interaction Dimension `json:"d3Interaction"`
- D4Topics Dimension `json:"d4Topics"`
- D5Rhythm Dimension `json:"d5Rhythm"`
- D6Visual Dimension `json:"d6Visual"`
- D7Conversion Dimension `json:"d7Conversion"`
- D8Risk Dimension `json:"d8Risk"`
- PersonaDraft PersonaDraft `json:"personaDraft"`
-}
-
-type Engagement struct {
- MeasuredPosts int `json:"measuredPosts"`
- MedianInteractions int `json:"medianInteractions"`
- AverageInteractions int `json:"averageInteractions"`
- PostsAboveThreshold int `json:"postsAboveThreshold"`
- Threshold int `json:"threshold"`
- Verdict string `json:"verdict"`
-}
-
-type StoredProfile struct {
- Username string `json:"username"`
- SourceType string `json:"sourceType,omitempty"`
- SourceLabel string `json:"sourceLabel,omitempty"`
- AnalyzedAt string `json:"analyzedAt"`
- PostCount int `json:"postCount"`
- Engagement Engagement `json:"engagement"`
- SamplePosts []Post `json:"samplePosts,omitempty"`
- Analysis map[string]Dimension `json:"analysis"`
- PersonaDraft string `json:"personaDraft"`
-}
-
-const (
- SourceTypeBenchmark = "benchmark"
- SourceTypeManual = "manual"
- maxReferenceTexts = 12
- maxReferenceRunes = 8000
- minReferenceRunes = 10
-)
-
-func ManualStyleBenchmark(label string) string {
- label = strings.TrimSpace(label)
- if label == "" {
- label = "手動貼文"
- }
- return "manual:" + label
-}
-
-func NormalizeReferenceTexts(referenceTexts []string, rawText string) ([]string, error) {
- texts := make([]string, 0, len(referenceTexts))
- for _, item := range referenceTexts {
- item = strings.TrimSpace(item)
- if item != "" {
- texts = append(texts, item)
- }
- }
- if len(texts) == 0 {
- rawText = strings.TrimSpace(rawText)
- if rawText != "" {
- for _, chunk := range splitReferenceRawText(rawText) {
- chunk = strings.TrimSpace(chunk)
- if chunk != "" {
- texts = append(texts, chunk)
- }
- }
- }
- }
- if len(texts) == 0 {
- return nil, fmt.Errorf("至少需要 1 段參考文字")
- }
- if len(texts) > maxReferenceTexts {
- return nil, fmt.Errorf("參考文字最多 %d 段", maxReferenceTexts)
- }
- totalRunes := 0
- out := make([]string, 0, len(texts))
- for _, item := range texts {
- runes := []rune(item)
- if len(runes) < minReferenceRunes {
- return nil, fmt.Errorf("每段參考文字至少 %d 字", minReferenceRunes)
- }
- totalRunes += len(runes)
- if totalRunes > maxReferenceRunes {
- return nil, fmt.Errorf("參考文字總長度不可超過 %d 字", maxReferenceRunes)
- }
- out = append(out, item)
- }
- return out, nil
-}
-
-func TextsToPosts(texts []string) []Post {
- posts := make([]Post, 0, len(texts))
- for _, text := range texts {
- posts = append(posts, Post{Text: text})
- }
- return posts
-}
-
-func splitReferenceRawText(raw string) []string {
- lines := strings.Split(raw, "\n")
- chunks := make([]string, 0, 4)
- var b strings.Builder
- flush := func() {
- if b.Len() == 0 {
- return
- }
- chunks = append(chunks, b.String())
- b.Reset()
- }
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if trimmed == "---" || trimmed == "———" || trimmed == "___" {
- flush()
- continue
- }
- if b.Len() > 0 {
- b.WriteByte('\n')
- }
- b.WriteString(line)
- }
- flush()
- if len(chunks) == 0 {
- return []string{strings.TrimSpace(raw)}
- }
- return chunks
-}
-
-func BuildUserPromptFromTexts(sourceLabel string, brief string, posts []Post) string {
- label := strings.TrimSpace(sourceLabel)
- if label == "" {
- label = "手動貼文"
- }
- var b strings.Builder
- fmt.Fprintf(&b, "文字樣本來源:%s\n", label)
- if brief := strings.TrimSpace(brief); brief != "" {
- fmt.Fprintf(&b, "人設定位補充:%s\n", brief)
- }
- b.WriteString("參考文字樣本(無互動數據,請只根據文字風格歸納):\n\n")
- limit := len(posts)
- if limit > maxReferenceTexts {
- limit = maxReferenceTexts
- }
- for i := 0; i < limit; i++ {
- text := posts[i].Text
- if len(text) > 500 {
- text = text[:500]
- }
- fmt.Fprintf(&b, "[%d]\n%s\n\n", i+1, text)
- }
- return b.String()
-}
-
-func BuildUserPrompt(username string, posts []Post) string {
- var b strings.Builder
- fmt.Fprintf(&b, "對標帳號:@%s\n近期貼文樣本:\n\n", strings.TrimPrefix(username, "@"))
- limit := len(posts)
- if limit > 12 {
- limit = 12
- }
- for i := 0; i < limit; i++ {
- post := posts[i]
- text := post.Text
- if len(text) > 500 {
- text = text[:500]
- }
- fmt.Fprintf(&b, "[%d] %d讚 %d回覆\n%s\n\n", i+1, post.LikeCount, post.ReplyCount, text)
- }
- return b.String()
-}
-
-func EvaluateEngagement(posts []Post, threshold int) Engagement {
- if threshold <= 0 {
- threshold = 10
- }
- values := make([]float64, 0, len(posts))
- for _, post := range posts {
- score := float64(post.LikeCount) + float64(post.ReplyCount)*2
- if score > 0 {
- values = append(values, score)
- }
- }
- median := 0.0
- if len(values) > 0 {
- sorted := append([]float64(nil), values...)
- for i := 0; i < len(sorted); i++ {
- for j := i + 1; j < len(sorted); j++ {
- if sorted[j] < sorted[i] {
- sorted[i], sorted[j] = sorted[j], sorted[i]
- }
- }
- }
- mid := len(sorted) / 2
- if len(sorted)%2 == 1 {
- median = sorted[mid]
- } else {
- median = (sorted[mid-1] + sorted[mid]) / 2
- }
- }
- avg := 0.0
- for _, v := range values {
- avg += v
- }
- if len(values) > 0 {
- avg /= float64(len(values))
- }
- above := 0
- for _, v := range values {
- if v >= float64(threshold) {
- above++
- }
- }
- verdict := "unknown"
- if len(values) >= 3 {
- if median >= float64(threshold) && above >= 3 {
- verdict = "strong"
- } else {
- verdict = "usable"
- }
- }
- return Engagement{
- MeasuredPosts: len(values),
- MedianInteractions: int(math.Round(median)),
- AverageInteractions: int(math.Round(avg)),
- PostsAboveThreshold: above,
- Threshold: threshold,
- Verdict: verdict,
- }
-}
-
-func SerializePersonaDraft(draft PersonaDraft) string {
- parts := []string{
- "【我是誰】\n" + strings.TrimSpace(draft.Identity),
- "【語氣】\n" + strings.TrimSpace(draft.Tone),
- "【對誰說】\n" + strings.TrimSpace(draft.Audience),
- "【開場習慣】\n" + strings.TrimSpace(draft.Hooks),
- "【語言指紋】\n" + strings.TrimSpace(draft.LanguageFingerprint),
- "【段落節奏】\n" + strings.TrimSpace(draft.Rhythm),
- "【標點與換行】\n" + strings.TrimSpace(draft.Punctuation),
- "【內容型態】\n" + strings.TrimSpace(draft.ContentPatterns),
- "【知識轉譯】\n" + strings.TrimSpace(draft.KnowledgeTranslation),
- "【互動與 CTA】\n" + strings.TrimSpace(draft.CTAStyle),
- "【代表句範例】\n" + strings.TrimSpace(draft.Examples),
- "【避免】\n" + strings.TrimSpace(draft.Avoid),
- }
- return strings.Join(parts, "\n\n")
-}
-
-func ParseLLMOutput(raw string) (LLMOutput, error) {
- payload, err := extractJSONObject(raw)
- if err != nil {
- return LLMOutput{}, err
- }
- var root map[string]json.RawMessage
- if err := llmjson.Unmarshal(payload, &root); err != nil {
- return LLMOutput{}, fmt.Errorf("parse style8d json: %w", err)
- }
- for _, key := range []string{"analysis", "style8d", "style8D", "result", "data", "output"} {
- if nested, ok := root[key]; ok {
- var merged map[string]json.RawMessage
- if err := llmjson.Unmarshal(nested, &merged); err == nil {
- for k, v := range merged {
- if _, exists := root[k]; !exists {
- root[k] = v
- }
- }
- }
- }
- }
- out := LLMOutput{
- D1Tone: parseDimension(root, "d1Tone", "d1", "D1", "tone"),
- D2Structure: parseDimension(root, "d2Structure", "d2", "D2", "structure"),
- D3Interaction: parseDimension(root, "d3Interaction", "d3", "D3", "interaction"),
- D4Topics: parseDimension(root, "d4Topics", "d4", "D4", "topics"),
- D5Rhythm: parseDimension(root, "d5Rhythm", "d5", "D5", "rhythm"),
- D6Visual: parseDimension(root, "d6Visual", "d6", "D6", "visual"),
- D7Conversion: parseDimension(root, "d7Conversion", "d7", "D7", "conversion"),
- D8Risk: parseDimension(root, "d8Risk", "d8", "D8", "risk"),
- PersonaDraft: parsePersonaDraft(root),
- }
- if err := validateOutput(out); err != nil {
- return LLMOutput{}, err
- }
- return out, nil
-}
-
-func buildSamplePosts(posts []Post) []Post {
- limit := len(posts)
- if limit > 12 {
- limit = 12
- }
- out := make([]Post, 0, limit)
- for i := 0; i < limit; i++ {
- post := posts[i]
- text := strings.TrimSpace(post.Text)
- if len(text) > 500 {
- text = text[:500]
- }
- out = append(out, Post{
- Text: text,
- Permalink: strings.TrimSpace(post.Permalink),
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- })
- }
- return out
-}
-
-func BuildStoredProfile(username string, posts []Post, out LLMOutput) StoredProfile {
- profile := baseStoredProfile(posts, out)
- profile.Username = strings.TrimPrefix(strings.TrimSpace(username), "@")
- profile.SourceType = SourceTypeBenchmark
- return profile
-}
-
-func BuildStoredProfileFromTexts(sourceLabel string, posts []Post, out LLMOutput) StoredProfile {
- label := strings.TrimSpace(sourceLabel)
- if label == "" {
- label = "手動貼文"
- }
- profile := baseStoredProfile(posts, out)
- profile.SourceType = SourceTypeManual
- profile.SourceLabel = label
- profile.Engagement.Verdict = "not_applicable"
- return profile
-}
-
-func baseStoredProfile(posts []Post, out LLMOutput) StoredProfile {
- return StoredProfile{
- AnalyzedAt: time.Now().UTC().Format(time.RFC3339),
- PostCount: len(posts),
- Engagement: EvaluateEngagement(posts, 10),
- SamplePosts: buildSamplePosts(posts),
- Analysis: map[string]Dimension{
- "d1Tone": trimDimension(out.D1Tone),
- "d2Structure": trimDimension(out.D2Structure),
- "d3Interaction": trimDimension(out.D3Interaction),
- "d4Topics": trimDimension(out.D4Topics),
- "d5Rhythm": trimDimension(out.D5Rhythm),
- "d6Visual": trimDimension(out.D6Visual),
- "d7Conversion": trimDimension(out.D7Conversion),
- "d8Risk": trimDimension(out.D8Risk),
- },
- PersonaDraft: SerializePersonaDraft(out.PersonaDraft),
- }
-}
-
-func (p StoredProfile) JSON() (string, error) {
- raw, err := json.Marshal(p)
- if err != nil {
- return "", err
- }
- return string(raw), nil
-}
-
-func trimDimension(d Dimension) Dimension {
- d.Summary = strings.TrimSpace(d.Summary)
- if len(d.Evidence) > 4 {
- d.Evidence = d.Evidence[:4]
- }
- out := make([]string, 0, len(d.Evidence))
- for _, item := range d.Evidence {
- item = strings.TrimSpace(item)
- if item != "" {
- out = append(out, item)
- }
- }
- d.Evidence = out
- return d
-}
-
-func validateOutput(out LLMOutput) error {
- missing := []string{}
- for name, dim := range map[string]Dimension{
- "D1": out.D1Tone, "D2": out.D2Structure, "D3": out.D3Interaction, "D4": out.D4Topics,
- "D5": out.D5Rhythm, "D6": out.D6Visual, "D7": out.D7Conversion, "D8": out.D8Risk,
- } {
- if strings.TrimSpace(dim.Summary) == "" {
- missing = append(missing, name)
- }
- }
- if len(missing) > 0 {
- return fmt.Errorf("LLM 回傳缺少維度摘要:%s", strings.Join(missing, ", "))
- }
- if strings.TrimSpace(out.PersonaDraft.Identity) == "" && strings.TrimSpace(out.PersonaDraft.Tone) == "" {
- return fmt.Errorf("LLM 回傳缺少 personaDraft")
- }
- return nil
-}
-
-var codeFenceRE = regexp.MustCompile("(?s)^```(?:json)?\\s*(.*?)\\s*```$")
-
-func extractJSONObject(raw string) ([]byte, error) {
- text := strings.TrimSpace(raw)
- if text == "" {
- return nil, fmt.Errorf("empty LLM response")
- }
- if m := codeFenceRE.FindStringSubmatch(text); len(m) == 2 {
- text = strings.TrimSpace(m[1])
- }
- start := strings.Index(text, "{")
- end := strings.LastIndex(text, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("LLM response does not contain JSON object")
- }
- return repairTruncatedJSON([]byte(text[start : end+1])), nil
-}
-
-// repairTruncatedJSON adds missing closing braces/brackets when the LLM output
-// was cut off mid-JSON. Returns nil if no repair was needed or possible.
-func repairTruncatedJSON(raw []byte) []byte {
- opens := bytes.Count(raw, []byte("{"))
- closes := bytes.Count(raw, []byte("}"))
- if closes < opens {
- raw = append(raw, bytes.Repeat([]byte("}"), opens-closes)...)
- }
- bracketOpens := bytes.Count(raw, []byte("["))
- bracketCloses := bytes.Count(raw, []byte("]"))
- if bracketCloses < bracketOpens {
- raw = append(raw, bytes.Repeat([]byte("]"), bracketOpens-bracketCloses)...)
- }
- return raw
-}
-
-func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension {
- for _, key := range keys {
- if raw, ok := root[key]; ok {
- if dim := decodeDimension(raw); strings.TrimSpace(dim.Summary) != "" {
- return dim
- }
- }
- }
- return Dimension{}
-}
-
-func decodeDimension(raw json.RawMessage) Dimension {
- var asString string
- if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
- return Dimension{Summary: strings.TrimSpace(asString)}
- }
- var obj map[string]json.RawMessage
- if err := llmjson.Unmarshal(raw, &obj); err != nil {
- return Dimension{}
- }
- summary := firstString(obj, "summary", "description", "analysis", "conclusion")
- evidence := firstStringSlice(obj, "evidence", "examples", "quotes", "proof")
- return Dimension{Summary: summary, Evidence: evidence}
-}
-
-func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft {
- for _, key := range []string{"personaDraft", "persona", "persona_draft", "voiceProfile"} {
- raw, ok := root[key]
- if !ok {
- continue
- }
- var asString string
- if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
- return PersonaDraft{Identity: strings.TrimSpace(asString)}
- }
- var obj map[string]json.RawMessage
- if err := llmjson.Unmarshal(raw, &obj); err != nil {
- continue
- }
- return PersonaDraft{
- Identity: firstString(obj, "identity", "role"),
- Tone: firstString(obj, "tone", "voice"),
- Audience: firstString(obj, "audience", "targetAudience"),
- Hooks: firstString(obj, "hooks", "openings"),
- LanguageFingerprint: firstString(obj, "languageFingerprint", "language_fingerprint", "signaturePhrases", "signature_phrases"),
- Rhythm: firstString(obj, "rhythm", "paragraphRhythm", "paragraph_rhythm"),
- Punctuation: firstString(obj, "punctuation", "visualSyntax", "visual_syntax"),
- ContentPatterns: firstString(obj, "contentPatterns", "content_patterns", "structures"),
- KnowledgeTranslation: firstString(obj, "knowledgeTranslation", "knowledge_translation", "researchTranslation", "research_translation"),
- CTAStyle: firstString(obj, "ctaStyle", "cta_style", "conversionStyle", "conversion_style"),
- Examples: firstString(obj, "examples", "sample"),
- Avoid: firstString(obj, "avoid", "risks"),
- }
- }
- return PersonaDraft{}
-}
-
-func firstString(obj map[string]json.RawMessage, keys ...string) string {
- for _, key := range keys {
- raw, ok := obj[key]
- if !ok {
- continue
- }
- var value string
- if err := llmjson.Unmarshal(raw, &value); err == nil {
- value = strings.TrimSpace(value)
- if value != "" {
- return value
- }
- }
- }
- return ""
-}
-
-func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string {
- for _, key := range keys {
- raw, ok := obj[key]
- if !ok {
- continue
- }
- var values []string
- if err := llmjson.Unmarshal(raw, &values); err == nil {
- out := make([]string, 0, len(values))
- for _, item := range values {
- item = strings.TrimSpace(item)
- if item != "" {
- out = append(out, item)
- }
- }
- if len(out) > 0 {
- return out
- }
- }
- var single string
- if err := llmjson.Unmarshal(raw, &single); err == nil {
- single = strings.TrimSpace(single)
- if single != "" {
- return []string{single}
- }
- }
- }
- return nil
-}
diff --git a/old/backend/internal/library/style8d/analyze_test.go b/old/backend/internal/library/style8d/analyze_test.go
deleted file mode 100644
index 978d605..0000000
--- a/old/backend/internal/library/style8d/analyze_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package style8d
-
-import (
- "strings"
- "testing"
-)
-
-func TestParseLLMOutputWithLiteralNewlinesInStrings(t *testing.T) {
- raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","languageFingerprint":"有時候","rhythm":"短段落","punctuation":"逗號","contentPatterns":"心情文","knowledgeTranslation":"先講情境","ctaStyle":"留言","examples":"有時候真的會累
-後來就習慣了","avoid":"硬銷"}}`
- out, err := ParseLLMOutput(raw)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if !strings.Contains(out.PersonaDraft.Examples, "後來就習慣了") {
- t.Fatalf("examples=%q", out.PersonaDraft.Examples)
- }
-}
-
-func TestParseLLMOutput(t *testing.T) {
- raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}`
- out, err := ParseLLMOutput(raw)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if out.D1Tone.Summary != "口語親近" {
- t.Fatalf("d1=%q", out.D1Tone.Summary)
- }
- profile := BuildStoredProfile("demo", []Post{{
- Text: "sample",
- Permalink: "https://www.threads.net/@demo/post/abc",
- LikeCount: 3,
- ReplyCount: 1,
- }}, out)
- if profile.Analysis["d1Tone"].Summary == "" {
- t.Fatal("stored profile missing d1")
- }
- if len(profile.SamplePosts) != 1 || profile.SamplePosts[0].Permalink == "" {
- t.Fatalf("samplePosts=%+v", profile.SamplePosts)
- }
- if !strings.Contains(profile.PersonaDraft, "【語言指紋】") {
- t.Fatalf("personaDraft missing language fingerprint: %q", profile.PersonaDraft)
- }
-}
diff --git a/old/backend/internal/library/style8d/prompt.go b/old/backend/internal/library/style8d/prompt.go
deleted file mode 100644
index 687848c..0000000
--- a/old/backend/internal/library/style8d/prompt.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package style8d
-
-import (
- "encoding/json"
- "strings"
-)
-
-var dimensionLabels = map[string]string{
- "d1Tone": "D1 語氣人格",
- "d2Structure": "D2 結構模板",
- "d3Interaction": "D3 互動方式",
- "d4Topics": "D4 主題分布",
- "d5Rhythm": "D5 發文節奏",
- "d6Visual": "D6 視覺語法",
- "d7Conversion": "D7 轉換方式",
- "d8Risk": "D8 風險紅線",
-}
-
-var dimensionOrder = []string{
- "d1Tone", "d2Structure", "d3Interaction", "d4Topics",
- "d5Rhythm", "d6Visual", "d7Conversion", "d8Risk",
-}
-
-// ParseStoredProfile decodes the persona.style_profile JSON blob.
-func ParseStoredProfile(raw string) (*StoredProfile, bool) {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return nil, false
- }
- var profile StoredProfile
- if err := json.Unmarshal([]byte(raw), &profile); err != nil {
- return nil, false
- }
- if len(profile.Analysis) == 0 && strings.TrimSpace(profile.PersonaDraft) == "" {
- return nil, false
- }
- return &profile, true
-}
-
-// HasReady8D returns true when D1–D8 summaries exist and can drive copy generation.
-// personaDraft or legacy persona text alone is not sufficient for matrix/copy gates.
-func HasReady8D(personaText, styleProfileJSON string) bool {
- _ = personaText
- return BuildStyle8DPromptBlock(styleProfileJSON) != ""
-}
-
-var copyVoiceDimensionOrder = []string{"d1Tone", "d8Risk"}
-
-// BuildStyle8DPromptBlock formats D1–D8 summaries for LLM prompts.
-func BuildStyle8DPromptBlock(styleProfileJSON string) string {
- return buildStylePromptBlock(styleProfileJSON, dimensionOrder, "【8D 風格策略】\n產文必須遵守:\n")
-}
-
-// BuildLightPersonaVoiceBlock keeps only tone + risk guardrails for copy generation.
-func BuildLightPersonaVoiceBlock(styleProfileJSON string) string {
- return buildStylePromptBlock(
- styleProfileJSON,
- copyVoiceDimensionOrder,
- "【語氣與禁忌參考】\n以下僅供口語風格與紅線參考,勿照搬句型、段落模板或固定 hook:\n",
- )
-}
-
-func buildStylePromptBlock(styleProfileJSON string, keys []string, header string) string {
- profile, ok := ParseStoredProfile(styleProfileJSON)
- if !ok {
- return ""
- }
- lines := make([]string, 0, len(keys))
- for _, key := range keys {
- summary := strings.TrimSpace(profile.Analysis[key].Summary)
- if summary == "" {
- continue
- }
- label := dimensionLabels[key]
- if label == "" {
- label = key
- }
- lines = append(lines, label+":"+summary)
- }
- if len(lines) == 0 {
- return ""
- }
- var b strings.Builder
- b.WriteString(header)
- b.WriteString(strings.Join(lines, "\n"))
- return b.String()
-}
-
-// ResolvePersonaBlock combines explicit persona positioning with analyzed writing fingerprints.
-// The persona field answers "who this is"; personaDraft/style profile answers "how this person says things".
-func ResolvePersonaBlock(personaText, styleProfileJSON, brief string) string {
- var parts []string
-
- profile, hasProfile := ParseStoredProfile(styleProfileJSON)
- persona := strings.TrimSpace(personaText)
- if persona != "" {
- parts = append(parts, "【人設定位】\n"+persona)
- } else if fallback := strings.TrimSpace(brief); fallback != "" {
- parts = append(parts, "【人設定位】\n"+fallback)
- }
-
- if hasProfile {
- if draft := strings.TrimSpace(profile.PersonaDraft); draft != "" {
- parts = append(parts, "【語言指紋與寫作規則】\n"+draft)
- }
- }
-
- if block := BuildLightPersonaVoiceBlock(styleProfileJSON); block != "" {
- parts = append(parts, block)
- }
- return strings.TrimSpace(strings.Join(parts, "\n\n"))
-}
diff --git a/old/backend/internal/library/style8d/prompt_test.go b/old/backend/internal/library/style8d/prompt_test.go
deleted file mode 100644
index bbd42c8..0000000
--- a/old/backend/internal/library/style8d/prompt_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package style8d
-
-import (
- "strings"
- "testing"
-)
-
-func TestResolvePersonaBlockUsesPersonaDraftWhenPersonaEmpty(t *testing.T) {
- raw := `{"username":"demo","analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【我是誰】\n生活觀察者"}`
- block := ResolvePersonaBlock("", raw, "")
- if block == "" {
- t.Fatal("expected non-empty block")
- }
- for _, part := range []string{"生活觀察者", "D1 語氣人格", "口語親近", "語言指紋與寫作規則"} {
- if !strings.Contains(block, part) {
- t.Fatalf("block missing %q: %q", part, block)
- }
- }
-}
-
-func TestResolvePersonaBlockCombinesPersonaAndStyleFingerprint(t *testing.T) {
- raw := `{"analysis":{"d1Tone":{"summary":"口語親近","evidence":[]}},"personaDraft":"【語言指紋】\n常用慢慢、其實當轉折"}`
- block := ResolvePersonaBlock("備孕陪伴帳號", raw, "")
- for _, part := range []string{"備孕陪伴帳號", "常用慢慢", "語言指紋與寫作規則"} {
- if !strings.Contains(block, part) {
- t.Fatalf("block missing %q: %q", part, block)
- }
- }
-}
-
-func TestHasReady8DFromAnalysisOnly(t *testing.T) {
- raw := `{"analysis":{"d2Structure":{"summary":"短句開場","evidence":[]}}}`
- if !HasReady8D("", raw) {
- t.Fatal("expected ready from analysis summary")
- }
-}
-
-func TestHasReady8DRejectsPersonaDraftOnly(t *testing.T) {
- raw := `{"personaDraft":"【我是誰】\n生活觀察者"}`
- if HasReady8D("語氣描述", raw) {
- t.Fatal("personaDraft without 8D dimensions should not be ready")
- }
-}
diff --git a/old/backend/internal/library/style8d/runner.go b/old/backend/internal/library/style8d/runner.go
deleted file mode 100644
index 4ed44c3..0000000
--- a/old/backend/internal/library/style8d/runner.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package style8d
-
-import "strings"
-
-// AnalyzeMode selects how reference samples are interpreted.
-type AnalyzeMode string
-
-const (
- AnalyzeModeBenchmark AnalyzeMode = "benchmark"
- AnalyzeModeManual AnalyzeMode = "manual"
-)
-
-// AnalyzeInput groups prompt + storage options for one 8D run.
-type AnalyzeInput struct {
- Mode AnalyzeMode
- Username string
- SourceLabel string
- Brief string
- Posts []Post
-}
-
-func (in AnalyzeInput) BuildUserPrompt() string {
- if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
- return BuildUserPromptFromTexts(in.SourceLabel, in.Brief, in.Posts)
- }
- return BuildUserPrompt(in.Username, in.Posts)
-}
-
-func (in AnalyzeInput) BuildStoredProfile(out LLMOutput) StoredProfile {
- if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
- return BuildStoredProfileFromTexts(in.SourceLabel, in.Posts, out)
- }
- return BuildStoredProfile(in.Username, in.Posts, out)
-}
-
-func (in AnalyzeInput) StyleBenchmark() string {
- if in.Mode == AnalyzeModeManual || strings.TrimSpace(in.Username) == "" {
- return ManualStyleBenchmark(in.SourceLabel)
- }
- return strings.TrimPrefix(strings.TrimSpace(in.Username), "@")
-}
diff --git a/old/backend/internal/library/style8d/text_input_test.go b/old/backend/internal/library/style8d/text_input_test.go
deleted file mode 100644
index d351898..0000000
--- a/old/backend/internal/library/style8d/text_input_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package style8d
-
-import "testing"
-
-func TestNormalizeReferenceTextsFromArray(t *testing.T) {
- texts, err := NormalizeReferenceTexts([]string{"第一段參考文字內容超過二十個字元", "第二段參考文字內容也超過二十個字元"}, "")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(texts) != 2 {
- t.Fatalf("texts=%v", texts)
- }
-}
-
-func TestNormalizeReferenceTextsFromRawText(t *testing.T) {
- raw := "第一段參考文字內容超過二十個字元\n還有第二行\n\n---\n\n第二段參考文字內容也超過二十個字元"
- texts, err := NormalizeReferenceTexts(nil, raw)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if len(texts) != 2 {
- t.Fatalf("texts=%v", texts)
- }
-}
-
-func TestBuildUserPromptFromTexts(t *testing.T) {
- got := BuildUserPromptFromTexts("我的舊文", "產品教學帳", TextsToPosts([]string{"這是一段超過二十個字的參考文字樣本內容"}))
- if got == "" {
- t.Fatal("empty prompt")
- }
- if !containsAll(got, "我的舊文", "產品教學帳", "[1]") {
- t.Fatalf("prompt=%q", got)
- }
-}
-
-func TestBuildStoredProfileFromTexts(t *testing.T) {
- out, err := ParseLLMOutput(`{"d1Tone":{"summary":"口語","evidence":[]},"d2Structure":{"summary":"短句","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","examples":"有時候真的會累","avoid":"硬銷"}}`)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- profile := BuildStoredProfileFromTexts("手動貼文", TextsToPosts([]string{"sample text long enough"}), out)
- if profile.SourceType != SourceTypeManual {
- t.Fatalf("sourceType=%q", profile.SourceType)
- }
- if profile.Engagement.Verdict != "not_applicable" {
- t.Fatalf("verdict=%q", profile.Engagement.Verdict)
- }
- if ManualStyleBenchmark("手動貼文") != "manual:手動貼文" {
- t.Fatal("manual benchmark mismatch")
- }
-}
-
-func containsAll(s string, parts ...string) bool {
- for _, part := range parts {
- if !contains(s, part) {
- return false
- }
- }
- return true
-}
-
-func contains(s, sub string) bool {
- return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
-}
-
-func indexOf(s, sub string) int {
- for i := 0; i+len(sub) <= len(s); i++ {
- if s[i:i+len(sub)] == sub {
- return i
- }
- }
- return -1
-}
diff --git a/old/backend/internal/library/threadsapi/client.go b/old/backend/internal/library/threadsapi/client.go
deleted file mode 100644
index c1c0125..0000000
--- a/old/backend/internal/library/threadsapi/client.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-const graphBaseURL = "https://graph.threads.net/v1.0"
-
-type Client struct {
- accessToken string
- http *http.Client
-}
-
-func NewClient(accessToken string) *Client {
- return &Client{
- accessToken: strings.TrimSpace(accessToken),
- http: &http.Client{
- Timeout: 25 * time.Second,
- },
- }
-}
-
-func (c *Client) Enabled() bool {
- return c != nil && c.accessToken != ""
-}
-
-type SearchItem struct {
- ID string `json:"id"`
- Text string `json:"text"`
- Permalink string `json:"permalink"`
- Username string `json:"username"`
- Timestamp string `json:"timestamp"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
-}
-
-type KeywordSearchOptions struct {
- Query string
- Limit int
- SearchType string // TOP or RECENT
-}
-
-func (c *Client) KeywordSearch(ctx context.Context, opts KeywordSearchOptions) ([]SearchItem, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- query := strings.TrimSpace(opts.Query)
- if query == "" {
- return nil, fmt.Errorf("threads keyword search query is required")
- }
- searchType := strings.TrimSpace(opts.SearchType)
- if searchType == "" {
- searchType = "TOP"
- }
- limit := opts.Limit
- if limit <= 0 {
- limit = 12
- }
- if limit > 50 {
- limit = 50
- }
-
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("q", query)
- params.Set("search_type", searchType)
- params.Set("fields", "id,text,permalink,username,timestamp,like_count,reply_count,repost_count,quote_count")
- params.Set("limit", fmt.Sprintf("%d", limit))
-
- endpoint := graphBaseURL + "/keyword_search?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- res, err := c.http.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
-
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return nil, err
- }
- if res.StatusCode != http.StatusOK {
- return nil, parseAPIError(body, res.StatusCode)
- }
-
- var payload struct {
- Data []SearchItem `json:"data"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse threads keyword_search: %w", err)
- }
- return payload.Data, nil
-}
-
-func parseAPIError(body []byte, status int) error {
- var payload struct {
- Error struct {
- Message string `json:"message"`
- Type string `json:"type"`
- Code int `json:"code"`
- ErrorSubcode int `json:"error_subcode"`
- ErrorUserMsg string `json:"error_user_msg"`
- FbtraceID string `json:"fbtrace_id"`
- } `json:"error"`
- ErrorMessage string `json:"error_message"`
- }
- _ = json.Unmarshal(body, &payload)
- msg := strings.TrimSpace(payload.Error.ErrorUserMsg)
- if msg == "" {
- msg = strings.TrimSpace(payload.Error.Message)
- }
- if msg == "" {
- msg = strings.TrimSpace(payload.ErrorMessage)
- }
- if msg == "" {
- msg = strings.TrimSpace(string(body))
- }
- if len(msg) > 400 {
- msg = msg[:400]
- }
- out := fmt.Sprintf("threads api %d: %s", status, msg)
- if payload.Error.Code != 0 || payload.Error.ErrorSubcode != 0 {
- out += fmt.Sprintf(" (code=%d subcode=%d)", payload.Error.Code, payload.Error.ErrorSubcode)
- }
- if trace := strings.TrimSpace(payload.Error.FbtraceID); trace != "" {
- out += fmt.Sprintf(" [fbtrace_id=%s]", trace)
- }
- return fmt.Errorf("%s", out)
-}
diff --git a/old/backend/internal/library/threadsapi/client_error_test.go b/old/backend/internal/library/threadsapi/client_error_test.go
deleted file mode 100644
index d62e28a..0000000
--- a/old/backend/internal/library/threadsapi/client_error_test.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package threadsapi
-
-import (
- "strings"
- "testing"
-)
-
-func TestParseAPIErrorIncludesUserMessageAndCodes(t *testing.T) {
- body := []byte(`{"error":{"message":"An unknown error occurred","type":"OAuthException","code":1,"error_subcode":99,"error_user_msg":"圖片無法下載","fbtrace_id":"abc123"}}`)
- err := parseAPIError(body, 400)
- got := err.Error()
- if got == "" {
- t.Fatal("expected error")
- }
- for _, want := range []string{"400", "圖片無法下載", "code=1", "subcode=99", "fbtrace_id=abc123"} {
- if !strings.Contains(got, want) {
- t.Fatalf("parseAPIError() = %q, want fragment %q", got, want)
- }
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/threadsapi/extended.go b/old/backend/internal/library/threadsapi/extended.go
deleted file mode 100644
index 7f60492..0000000
--- a/old/backend/internal/library/threadsapi/extended.go
+++ /dev/null
@@ -1,522 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
-)
-
-const (
- // userThreadsListFields lists fields supported on GET /{user-id}/threads.
- userThreadsListFields = "id,text,permalink,timestamp,media_type,media_url,thumbnail_url,topic_tag,shortcode"
-)
-
-type MediaItem struct {
- ID string `json:"id"`
- Shortcode string `json:"shortcode"`
- Text string `json:"text"`
- Permalink string `json:"permalink"`
- Timestamp string `json:"timestamp"`
- MediaType string `json:"media_type"`
- MediaURL string `json:"media_url"`
- ThumbnailURL string `json:"thumbnail_url"`
- TopicTag string `json:"topic_tag"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
-}
-
-type ProfileLookupResult struct {
- Username string `json:"username"`
- Name string `json:"name"`
- ProfilePictureURL string `json:"profile_picture_url,omitempty"`
- Biography string `json:"biography,omitempty"`
- FollowerCount int `json:"follower_count,omitempty"`
- LikesCount int `json:"likes_count,omitempty"`
- QuotesCount int `json:"quotes_count,omitempty"`
- RepostsCount int `json:"reposts_count,omitempty"`
- ViewsCount int `json:"views_count,omitempty"`
- IsVerified bool `json:"is_verified,omitempty"`
-}
-
-type MentionItem 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"`
- RootPostID string `json:"root_post_id,omitempty"`
- ParentID string `json:"parent_id,omitempty"`
-}
-
-type LocationItem struct {
- ID string `json:"id"`
- Name string `json:"name"`
-}
-
-type InsightValue struct {
- Name string `json:"name"`
- Values []struct {
- Value int `json:"value"`
- } `json:"values"`
-}
-
-// UserThreads lists the authenticated user's own posts (threads_basic).
-func (c *Client) UserThreads(ctx context.Context, userID string, limit int) ([]MediaItem, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- userID = strings.TrimSpace(userID)
- if userID == "" {
- return nil, fmt.Errorf("threads user id is required")
- }
- if limit <= 0 {
- limit = 25
- }
- if limit > 100 {
- limit = 100
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("fields", userThreadsListFields)
- params.Set("limit", fmt.Sprintf("%d", limit))
- endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads?" + params.Encode()
- return c.getMediaList(ctx, endpoint)
-}
-
-// DeleteMedia deletes a Threads post (threads_delete).
-func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error {
- if !c.Enabled() {
- return fmt.Errorf("threads api access token is required")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return fmt.Errorf("threads media id is required")
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
- if err != nil {
- return err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return err
- }
- if status != http.StatusOK {
- return parseAPIError(body, status)
- }
- return nil
-}
-
-// ProfilePosts lists recent public posts for a username (threads_profile_discovery).
-func (c *Client) ProfilePosts(ctx context.Context, username string, limit int, after string) ([]MediaItem, string, error) {
- if !c.Enabled() {
- return nil, "", fmt.Errorf("threads api access token is required")
- }
- username = strings.TrimPrefix(strings.TrimSpace(username), "@")
- if username == "" {
- return nil, "", fmt.Errorf("username is required")
- }
- if limit <= 0 {
- limit = 50
- }
- if limit > 100 {
- limit = 100
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("username", username)
- params.Set("fields", "id,shortcode,permalink,text,timestamp")
- params.Set("limit", fmt.Sprintf("%d", limit))
- if after = strings.TrimSpace(after); after != "" {
- params.Set("after", after)
- }
- endpoint := graphBaseURL + "/profile_posts?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, "", err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, "", err
- }
- if status != http.StatusOK {
- return nil, "", parseAPIError(body, status)
- }
- var payload struct {
- Data []MediaItem `json:"data"`
- Paging struct {
- Cursors struct {
- After string `json:"after"`
- } `json:"cursors"`
- } `json:"paging"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, "", fmt.Errorf("parse profile_posts: %w", err)
- }
- return payload.Data, strings.TrimSpace(payload.Paging.Cursors.After), nil
-}
-
-// FindMediaIDByAuthorShortcode scans profile_posts pages until shortcode is found.
-func (c *Client) FindMediaIDByAuthorShortcode(ctx context.Context, username, shortcode string) (string, error) {
- username = strings.TrimPrefix(strings.TrimSpace(username), "@")
- shortcode = strings.TrimSpace(shortcode)
- if username == "" || shortcode == "" {
- return "", fmt.Errorf("username and shortcode are required")
- }
- wantSuffix := "/post/" + shortcode
- after := ""
- for page := 0; page < 3; page++ {
- items, next, err := c.ProfilePosts(ctx, username, 50, after)
- if err != nil {
- return "", err
- }
- for _, item := range items {
- id := strings.TrimSpace(item.ID)
- if !IsNumericMediaID(id) {
- continue
- }
- if strings.EqualFold(strings.TrimSpace(item.Shortcode), shortcode) {
- return id, nil
- }
- link := strings.TrimSpace(item.Permalink)
- if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) {
- return id, nil
- }
- }
- if next == "" || len(items) == 0 {
- break
- }
- after = next
- }
- return "", fmt.Errorf("profile_posts did not include @%s post %s", username, shortcode)
-}
-
-// ProfileLookup fetches a public profile by username (threads_profile_discovery).
-func (c *Client) ProfileLookup(ctx context.Context, username string) (*ProfileLookupResult, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- username = strings.TrimPrefix(strings.TrimSpace(username), "@")
- if username == "" {
- return nil, fmt.Errorf("username is required")
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("username", username)
- params.Set("fields", "username,name,profile_picture_url,biography,follower_count,likes_count,quotes_count,reposts_count,views_count,is_verified")
- endpoint := graphBaseURL + "/profile_lookup?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var out ProfileLookupResult
- if err := json.Unmarshal(body, &out); err != nil {
- return nil, fmt.Errorf("parse profile_lookup: %w", err)
- }
- return &out, nil
-}
-
-type mentionItemRaw 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"`
- RootPost json.RawMessage `json:"root_post"`
- ParentID string `json:"parent_id"`
-}
-
-func normalizeMentionItem(raw mentionItemRaw) MentionItem {
- return MentionItem{
- ID: strings.TrimSpace(raw.ID),
- Text: strings.TrimSpace(raw.Text),
- Username: strings.TrimSpace(raw.Username),
- Permalink: strings.TrimSpace(raw.Permalink),
- Timestamp: strings.TrimSpace(raw.Timestamp),
- MediaType: strings.TrimSpace(raw.MediaType),
- IsReply: raw.IsReply,
- IsQuotePost: raw.IsQuotePost,
- HasReplies: raw.HasReplies,
- RootPostID: parseMediaRefID(raw.RootPost),
- ParentID: strings.TrimSpace(raw.ParentID),
- }
-}
-
-// UserMentions lists the first page of posts mentioning the user (threads_manage_mentions).
-func (c *Client) UserMentions(ctx context.Context, userID string, limit int) ([]MentionItem, error) {
- items, _, err := c.UserMentionsPage(ctx, userID, limit, "")
- return items, err
-}
-
-// UserMentionsPage lists one cursor page of mentions. nextAfter is empty when there is no next page.
-func (c *Client) UserMentionsPage(ctx context.Context, userID string, limit int, after string) ([]MentionItem, string, error) {
- if !c.Enabled() {
- return nil, "", fmt.Errorf("threads api access token is required")
- }
- userID = strings.TrimSpace(userID)
- if userID == "" {
- return nil, "", fmt.Errorf("threads user id is required")
- }
- if limit <= 0 {
- limit = 25
- }
- if limit > 100 {
- limit = 100
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply,is_quote_post,has_replies,root_post,parent_id")
- params.Set("limit", fmt.Sprintf("%d", limit))
- if after = strings.TrimSpace(after); after != "" {
- params.Set("after", after)
- }
- endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/mentions?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, "", err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, "", err
- }
- if status != http.StatusOK {
- return nil, "", parseAPIError(body, status)
- }
- var payload struct {
- Data []mentionItemRaw `json:"data"`
- Paging struct {
- Cursors struct {
- After string `json:"after"`
- } `json:"cursors"`
- } `json:"paging"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, "", fmt.Errorf("parse mentions: %w", err)
- }
- out := make([]MentionItem, 0, len(payload.Data))
- for _, raw := range payload.Data {
- out = append(out, normalizeMentionItem(raw))
- }
- return out, strings.TrimSpace(payload.Paging.Cursors.After), nil
-}
-
-// LocationSearch searches public locations (threads_location_tagging).
-func (c *Client) LocationSearch(ctx context.Context, query string, limit int) ([]LocationItem, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- query = strings.TrimSpace(query)
- if query == "" {
- return nil, fmt.Errorf("location query is required")
- }
- if limit <= 0 {
- limit = 10
- }
- if limit > 50 {
- limit = 50
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("q", query)
- params.Set("fields", "id,name")
- params.Set("limit", fmt.Sprintf("%d", limit))
- endpoint := graphBaseURL + "/location_search?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var payload struct {
- Data []LocationItem `json:"data"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse location_search: %w", err)
- }
- return payload.Data, nil
-}
-
-// ShareToInstagram cross-posts a Threads media to linked Instagram (threads_share_to_instagram).
-func (c *Client) ShareToInstagram(ctx context.Context, mediaID string) error {
- if !c.Enabled() {
- return fmt.Errorf("threads api access token is required")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return fmt.Errorf("threads media id is required")
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/crosspost?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
- if err != nil {
- return err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return err
- }
- if status != http.StatusOK {
- return parseAPIError(body, status)
- }
- return nil
-}
-
-// HideReply hides a reply on the user's thread (threads_manage_replies).
-func (c *Client) HideReply(ctx context.Context, replyID string, hide bool) error {
- if !c.Enabled() {
- return fmt.Errorf("threads api access token is required")
- }
- replyID = strings.TrimSpace(replyID)
- if replyID == "" {
- return fmt.Errorf("reply id is required")
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("hide", fmt.Sprintf("%t", hide))
- endpoint := graphBaseURL + "/" + url.PathEscape(replyID) + "?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
- if err != nil {
- return err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return err
- }
- if status != http.StatusOK {
- return parseAPIError(body, status)
- }
- return nil
-}
-
-// MediaInsights fetches per-post lifetime metrics (threads_manage_insights).
-func (c *Client) MediaInsights(ctx context.Context, mediaID string, metrics []string) (map[string]int, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return nil, fmt.Errorf("threads media id is required")
- }
- if len(metrics) == 0 {
- metrics = []string{"views", "likes", "replies", "reposts", "quotes", "shares"}
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("metric", strings.Join(metrics, ","))
- endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "/insights?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var payload struct {
- Data []InsightValue `json:"data"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse media insights: %w", err)
- }
- out := make(map[string]int, len(payload.Data))
- for _, item := range payload.Data {
- name := strings.TrimSpace(strings.ToLower(item.Name))
- if name == "" || len(item.Values) == 0 {
- continue
- }
- out[name] = item.Values[0].Value
- }
- return out, nil
-}
-
-// UserInsights fetches profile-level insights (threads_manage_insights).
-func (c *Client) UserInsights(ctx context.Context, userID string, metrics []string) ([]InsightValue, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- userID = strings.TrimSpace(userID)
- if userID == "" {
- return nil, fmt.Errorf("threads user id is required")
- }
- if len(metrics) == 0 {
- metrics = []string{"views", "likes", "replies", "reposts", "quotes", "followers_count"}
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("metric", strings.Join(metrics, ","))
- endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "/threads_insights?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var payload struct {
- Data []InsightValue `json:"data"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse threads_insights: %w", err)
- }
- return payload.Data, nil
-}
-
-func (c *Client) getMediaList(ctx context.Context, endpoint string) ([]MediaItem, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- res, err := c.http.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return nil, err
- }
- if res.StatusCode != http.StatusOK {
- return nil, parseAPIError(body, res.StatusCode)
- }
- var payload struct {
- Data []MediaItem `json:"data"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse threads media list: %w", err)
- }
- return payload.Data, nil
-}
diff --git a/old/backend/internal/library/threadsapi/fields_test.go b/old/backend/internal/library/threadsapi/fields_test.go
deleted file mode 100644
index ae3314f..0000000
--- a/old/backend/internal/library/threadsapi/fields_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package threadsapi
-
-import (
- "strings"
- "testing"
-)
-
-func TestUserThreadsListFieldsExcludeEngagement(t *testing.T) {
- if fieldInList(userThreadsListFields, "like_count") || fieldInList(userThreadsListFields, "reply_count") {
- t.Fatalf("userThreadsListFields must not request engagement fields: %q", userThreadsListFields)
- }
-}
-
-func TestMediaConversationFieldsExcludeEngagement(t *testing.T) {
- if fieldInList(mediaConversationFields, "like_count") {
- t.Fatalf("mediaConversationFields must not request like_count: %q", mediaConversationFields)
- }
-}
-
-func fieldInList(fields, name string) bool {
- for _, part := range strings.Split(fields, ",") {
- if strings.TrimSpace(part) == name {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/threadsapi/media.go b/old/backend/internal/library/threadsapi/media.go
deleted file mode 100644
index b0e203d..0000000
--- a/old/backend/internal/library/threadsapi/media.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-type MediaDetail 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"`
-}
-
-func (c *Client) GetMediaDetail(ctx context.Context, mediaID string) (*MediaDetail, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return nil, fmt.Errorf("threads media id is required")
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("fields", "id,text,username,permalink,timestamp,media_type,is_reply")
- endpoint := graphBaseURL + "/" + url.PathEscape(mediaID) + "?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var out MediaDetail
- if err := json.Unmarshal(body, &out); err != nil {
- return nil, fmt.Errorf("parse media detail: %w", err)
- }
- out.ID = strings.TrimSpace(out.ID)
- if out.ID == "" {
- return nil, fmt.Errorf("threads media detail missing id")
- }
- return &out, nil
-}
-
-type MediaStats struct {
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count"`
- QuoteCount int `json:"quote_count"`
- Views int `json:"views,omitempty"`
- Shares int `json:"shares,omitempty"`
-}
-
-// GetMediaStats loads engagement counts via GET /{media-id}/insights.
-// Meta does not expose like_count on the media object itself (requesting those fields returns API errors).
-func GetMediaStats(ctx context.Context, accessToken, mediaID string) (*MediaStats, error) {
- return NewClient(accessToken).GetMediaStats(ctx, mediaID)
-}
-
-func (c *Client) GetMediaStats(ctx context.Context, mediaID string) (*MediaStats, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return nil, fmt.Errorf("threads media id is required")
- }
- metrics, err := c.MediaInsights(ctx, mediaID, []string{"likes", "replies", "reposts", "quotes", "views", "shares"})
- if err != nil {
- if IsPermissionError(err) {
- return nil, fmt.Errorf("%w(貼文互動數需 threads_manage_insights,請確認 OAuth 已授權)", err)
- }
- return nil, err
- }
- return mapInsightMetrics(metrics), nil
-}
-
-func mapInsightMetrics(metrics map[string]int) *MediaStats {
- if metrics == nil {
- metrics = map[string]int{}
- }
- return &MediaStats{
- LikeCount: metrics["likes"],
- ReplyCount: metrics["replies"],
- RepostCount: metrics["reposts"],
- QuoteCount: metrics["quotes"],
- Views: metrics["views"],
- Shares: metrics["shares"],
- }
-}
-
-type TokenRefreshResult struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type"`
- ExpiresIn int `json:"expires_in"`
-}
-
-func RefreshAccessToken(ctx context.Context, currentToken string) (*TokenRefreshResult, error) {
- u := fmt.Sprintf("%s/refresh_access_token", graphBaseURL)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
- if err != nil {
- return nil, err
- }
- q := url.Values{}
- q.Set("grant_type", "th_refresh_token")
- q.Set("access_token", currentToken)
- req.URL.RawQuery = q.Encode()
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("threads api token refresh error: %s", resp.Status)
- }
-
- var result TokenRefreshResult
- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
- return nil, err
- }
- if result.AccessToken == "" {
- return nil, fmt.Errorf("threads api token refresh returned empty token")
- }
- return &result, nil
-}
-
-func NsFromNow(seconds int) int64 {
- return time.Now().UnixNano() + int64(seconds)*1e9
-}
diff --git a/old/backend/internal/library/threadsapi/media_test.go b/old/backend/internal/library/threadsapi/media_test.go
deleted file mode 100644
index 5558c61..0000000
--- a/old/backend/internal/library/threadsapi/media_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package threadsapi
-
-import "testing"
-
-func TestMapInsightMetrics(t *testing.T) {
- stats := mapInsightMetrics(map[string]int{
- "likes": 10,
- "replies": 3,
- "reposts": 2,
- "quotes": 1,
- "views": 99,
- "shares": 4,
- })
- if stats.LikeCount != 10 || stats.ReplyCount != 3 || stats.RepostCount != 2 || stats.QuoteCount != 1 {
- t.Fatalf("unexpected counts: %+v", stats)
- }
- if stats.Views != 99 || stats.Shares != 4 {
- t.Fatalf("unexpected views/shares: %+v", stats)
- }
-}
-
-func TestMapInsightMetricsEmpty(t *testing.T) {
- stats := mapInsightMetrics(nil)
- if stats.LikeCount != 0 || stats.ReplyCount != 0 {
- t.Fatalf("expected zero stats, got %+v", stats)
- }
-}
diff --git a/old/backend/internal/library/threadsapi/oauth.go b/old/backend/internal/library/threadsapi/oauth.go
deleted file mode 100644
index f3b38e2..0000000
--- a/old/backend/internal/library/threadsapi/oauth.go
+++ /dev/null
@@ -1,257 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-)
-
-const (
- threadsAuthorizeURL = "https://threads.net/oauth/authorize"
- threadsShortTokenURL = "https://graph.threads.net/oauth/access_token"
- threadsLongLivedTokenURL = "https://graph.threads.net/access_token"
-)
-
-// flexString unmarshals JSON string or number (Meta often returns user_id as a number).
-type flexString string
-
-func (s *flexString) UnmarshalJSON(data []byte) error {
- if len(data) == 0 || string(data) == "null" {
- *s = ""
- return nil
- }
- if data[0] == '"' {
- var str string
- if err := json.Unmarshal(data, &str); err != nil {
- return err
- }
- *s = flexString(str)
- return nil
- }
- var num json.Number
- if err := json.Unmarshal(data, &num); err != nil {
- return err
- }
- *s = flexString(num.String())
- return nil
-}
-
-func (s flexString) String() string {
- return strings.TrimSpace(string(s))
-}
-
-type OAuthConfig struct {
- AppID string
- AppSecret string
- RedirectURI string
- SuccessRedirect string
-}
-
-func (c OAuthConfig) Enabled() bool {
- return strings.TrimSpace(c.AppID) != "" &&
- strings.TrimSpace(c.AppSecret) != "" &&
- strings.TrimSpace(c.RedirectURI) != ""
-}
-
-type TokenExchangeResult struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type"`
- ExpiresIn int `json:"expires_in"`
- UserID flexString `json:"user_id"`
-}
-
-type UserProfile struct {
- ID flexString `json:"id"`
- Username string `json:"username"`
- Name string `json:"name"`
-}
-
-func BuildAuthorizeURL(cfg OAuthConfig, state string) (string, error) {
- if !cfg.Enabled() {
- return "", fmt.Errorf("threads oauth is not configured")
- }
- state = strings.TrimSpace(state)
- if state == "" {
- return "", fmt.Errorf("oauth state is required")
- }
- params := url.Values{}
- params.Set("client_id", strings.TrimSpace(cfg.AppID))
- params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
- params.Set("scope", OAuthScopeString())
- params.Set("response_type", "code")
- params.Set("state", state)
- return threadsAuthorizeURL + "?" + params.Encode(), nil
-}
-
-func ExchangeCodeForToken(ctx context.Context, cfg OAuthConfig, code string) (*TokenExchangeResult, error) {
- if !cfg.Enabled() {
- return nil, fmt.Errorf("threads oauth is not configured")
- }
- code = strings.TrimSpace(code)
- if code == "" {
- return nil, fmt.Errorf("authorization code is required")
- }
- // Meta may append #_ to the redirect code; strip suffix if present.
- if idx := strings.Index(code, "#_"); idx >= 0 {
- code = code[:idx]
- }
-
- params := url.Values{}
- params.Set("client_id", strings.TrimSpace(cfg.AppID))
- params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
- params.Set("grant_type", "authorization_code")
- params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
- params.Set("code", code)
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsShortTokenURL+"?"+params.Encode(), nil)
- if err != nil {
- return nil, err
- }
-
- body, status, err := oauthDoRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var result TokenExchangeResult
- if err := json.Unmarshal(body, &result); err != nil {
- return nil, fmt.Errorf("parse threads token response: %w", err)
- }
- if strings.TrimSpace(result.AccessToken) == "" {
- return nil, fmt.Errorf("threads token exchange returned empty access_token")
- }
- return &result, nil
-}
-
-func ExchangeLongLivedToken(ctx context.Context, cfg OAuthConfig, shortLivedToken string) (*TokenExchangeResult, error) {
- if !cfg.Enabled() {
- return nil, fmt.Errorf("threads oauth is not configured")
- }
- shortLivedToken = strings.TrimSpace(shortLivedToken)
- if shortLivedToken == "" {
- return nil, fmt.Errorf("short-lived access token is required")
- }
-
- params := url.Values{}
- params.Set("grant_type", "th_exchange_token")
- params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
- params.Set("access_token", shortLivedToken)
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsLongLivedTokenURL+"?"+params.Encode(), nil)
- if err != nil {
- return nil, err
- }
- body, status, err := oauthDoRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var result TokenExchangeResult
- if err := json.Unmarshal(body, &result); err != nil {
- return nil, fmt.Errorf("parse long-lived token response: %w", err)
- }
- if strings.TrimSpace(result.AccessToken) == "" {
- return nil, fmt.Errorf("long-lived token exchange returned empty access_token")
- }
- return &result, nil
-}
-
-func GetUserProfile(ctx context.Context, accessToken, userID string) (*UserProfile, error) {
- accessToken = strings.TrimSpace(accessToken)
- userID = strings.TrimSpace(userID)
- if accessToken == "" {
- return nil, fmt.Errorf("access token is required")
- }
- if userID == "" {
- return GetMeProfile(ctx, accessToken)
- }
-
- params := url.Values{}
- params.Set("access_token", accessToken)
- params.Set("fields", "id,username,name")
-
- endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "?" + params.Encode()
- return fetchUserProfile(ctx, endpoint)
-}
-
-// GetMeProfile loads the authenticated Threads user via /me (preferred after OAuth).
-func GetMeProfile(ctx context.Context, accessToken string) (*UserProfile, error) {
- accessToken = strings.TrimSpace(accessToken)
- if accessToken == "" {
- return nil, fmt.Errorf("access token is required")
- }
- params := url.Values{}
- params.Set("access_token", accessToken)
- params.Set("fields", "id,username,name")
- endpoint := graphBaseURL + "/me?" + params.Encode()
- return fetchUserProfile(ctx, endpoint)
-}
-
-// ThreadsUserIDFromToken picks user_id returned by the OAuth token exchange.
-func ThreadsUserIDFromToken(short, long *TokenExchangeResult) string {
- if short != nil {
- if id := short.UserID.String(); id != "" {
- return id
- }
- }
- if long != nil {
- return long.UserID.String()
- }
- return ""
-}
-
-// IsPermissionError reports Meta permission / app-review / tester-list errors.
-func IsPermissionError(err error) bool {
- if err == nil {
- return false
- }
- msg := strings.ToLower(err.Error())
- return strings.Contains(msg, "threads_basic") ||
- strings.Contains(msg, "app review") ||
- strings.Contains(msg, "testers")
-}
-
-func fetchUserProfile(ctx context.Context, endpoint string) (*UserProfile, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- body, status, err := oauthDoRequest(req)
- if err != nil {
- return nil, err
- }
- if status != http.StatusOK {
- return nil, parseAPIError(body, status)
- }
- var profile UserProfile
- if err := json.Unmarshal(body, &profile); err != nil {
- return nil, fmt.Errorf("parse threads user profile: %w", err)
- }
- if profile.ID.String() == "" {
- return nil, fmt.Errorf("threads user profile missing id")
- }
- return &profile, nil
-}
-
-func oauthDoRequest(req *http.Request) ([]byte, int, error) {
- client := &http.Client{Timeout: 25 * time.Second}
- res, err := client.Do(req)
- if err != nil {
- return nil, 0, err
- }
- defer res.Body.Close()
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return nil, res.StatusCode, err
- }
- return body, res.StatusCode, nil
-}
diff --git a/old/backend/internal/library/threadsapi/oauth_permission_test.go b/old/backend/internal/library/threadsapi/oauth_permission_test.go
deleted file mode 100644
index 4b3f7b4..0000000
--- a/old/backend/internal/library/threadsapi/oauth_permission_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threadsapi
-
-import (
- "errors"
- "testing"
-)
-
-func TestIsPermissionError(t *testing.T) {
- err := errors.New(`threads api 400: This action requires the threads_basic permission`)
- if !IsPermissionError(err) {
- t.Fatal("expected permission error")
- }
- if IsPermissionError(errors.New("network timeout")) {
- t.Fatal("unexpected permission match")
- }
-}
-
-func TestThreadsUserIDFromToken(t *testing.T) {
- short := &TokenExchangeResult{UserID: flexString("111")}
- long := &TokenExchangeResult{UserID: flexString("222")}
- if got := ThreadsUserIDFromToken(short, long); got != "111" {
- t.Fatalf("got %q", got)
- }
- short.UserID = ""
- if got := ThreadsUserIDFromToken(short, long); got != "222" {
- t.Fatalf("got %q", got)
- }
-}
diff --git a/old/backend/internal/library/threadsapi/oauth_test.go b/old/backend/internal/library/threadsapi/oauth_test.go
deleted file mode 100644
index 163b864..0000000
--- a/old/backend/internal/library/threadsapi/oauth_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package threadsapi
-
-import (
- "encoding/json"
- "testing"
-)
-
-func TestTokenExchangeResultUnmarshalNumericUserID(t *testing.T) {
- raw := []byte(`{"access_token":"tok","token_type":"bearer","expires_in":3600,"user_id":123456789}`)
- var result TokenExchangeResult
- if err := json.Unmarshal(raw, &result); err != nil {
- t.Fatalf("unmarshal: %v", err)
- }
- if result.UserID.String() != "123456789" {
- t.Fatalf("user_id = %q, want 123456789", result.UserID.String())
- }
-}
-
-func TestUserProfileUnmarshalNumericID(t *testing.T) {
- raw := []byte(`{"id":987654321,"username":"demo","name":"Demo"}`)
- var profile UserProfile
- if err := json.Unmarshal(raw, &profile); err != nil {
- t.Fatalf("unmarshal: %v", err)
- }
- if profile.ID.String() != "987654321" {
- t.Fatalf("id = %q, want 987654321", profile.ID.String())
- }
-}
diff --git a/old/backend/internal/library/threadsapi/publish.go b/old/backend/internal/library/threadsapi/publish.go
deleted file mode 100644
index 20e89cb..0000000
--- a/old/backend/internal/library/threadsapi/publish.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-
-)
-
-type PublishResult struct {
- MediaID string `json:"media_id"`
- Permalink string `json:"permalink"`
-}
-
-type PublishTextInput struct {
- ThreadsUserID string
- AccessToken string
- Text string
- TopicTag string
- ImageURL string
- ImageURLs []string
-}
-
-type PublishReplyInput struct {
- ThreadsUserID string
- AccessToken string
- ReplyToID string
- Text string
- ImageURL string
- ImageURLs []string
-}
-
-func collectImageURLs(single string, list []string) []string {
- if len(list) > 0 {
- return normalizeImageURLs(list)
- }
- single = strings.TrimSpace(single)
- if single == "" {
- return nil
- }
- return []string{single}
-}
-
-// PublishText posts a new text or image thread via Graph API.
-func PublishText(ctx context.Context, in PublishTextInput) (*PublishResult, error) {
- return publishMedia(ctx, publishMediaInput{
- ThreadsUserID: in.ThreadsUserID,
- AccessToken: in.AccessToken,
- Text: in.Text,
- TopicTag: in.TopicTag,
- ImageURLs: collectImageURLs(in.ImageURL, in.ImageURLs),
- })
-}
-
-// PublishReply posts a text or image reply via Graph API.
-func PublishReply(ctx context.Context, in PublishReplyInput) (*PublishResult, error) {
- replyTo := strings.TrimSpace(in.ReplyToID)
- if replyTo == "" {
- return nil, fmt.Errorf("reply_to_id is required")
- }
- return publishMedia(ctx, publishMediaInput{
- ThreadsUserID: in.ThreadsUserID,
- AccessToken: in.AccessToken,
- Text: in.Text,
- ReplyToID: replyTo,
- ImageURLs: collectImageURLs(in.ImageURL, in.ImageURLs),
- })
-}
-
-func urlValues(token string) url.Values {
- params := url.Values{}
- params.Set("access_token", token)
- return params
-}
-
-func postThreadsContainer(ctx context.Context, userID string, params url.Values) (string, error) {
- endpoint := graphBaseURL + "/" + userID + "/threads?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
- if err != nil {
- return "", err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return "", err
- }
- if status != http.StatusOK {
- return "", parseAPIError(body, status)
- }
- var payload struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return "", err
- }
- if strings.TrimSpace(payload.ID) == "" {
- return "", fmt.Errorf("threads api did not return container id")
- }
- return payload.ID, nil
-}
-
-func createTextContainer(ctx context.Context, userID, token, text, topicTag string) (string, error) {
- params := urlValues(token)
- params.Set("media_type", "TEXT")
- params.Set("text", text)
- if tag := normalizeTopicTag(topicTag); tag != "" {
- params.Set("topic_tag", tag)
- }
- return postThreadsContainer(ctx, userID, params)
-}
-
-func normalizeTopicTag(value string) string {
- tag := strings.TrimSpace(value)
- tag = strings.TrimLeft(tag, "#")
- tag = strings.TrimSpace(tag)
- if len([]rune(tag)) > 40 {
- runes := []rune(tag)
- tag = string(runes[:40])
- }
- return tag
-}
-
-func createImageContainer(ctx context.Context, userID, token, text, imageURL, topicTag string) (string, error) {
- params := urlValues(token)
- params.Set("media_type", "IMAGE")
- params.Set("image_url", imageURL)
- if text != "" {
- params.Set("text", text)
- }
- if tag := normalizeTopicTag(topicTag); tag != "" {
- params.Set("topic_tag", tag)
- }
- return postThreadsContainer(ctx, userID, params)
-}
-
-func createImageReplyContainer(ctx context.Context, userID, token, replyTo, text, imageURL string) (string, error) {
- params := urlValues(token)
- params.Set("media_type", "IMAGE")
- params.Set("image_url", imageURL)
- if text != "" {
- params.Set("text", text)
- }
- params.Set("reply_to_id", replyTo)
- return postThreadsContainer(ctx, userID, params)
-}
-
-func createReplyContainer(ctx context.Context, userID, token, replyTo, text string) (string, error) {
- params := urlValues(token)
- params.Set("media_type", "TEXT")
- params.Set("text", text)
- params.Set("reply_to_id", replyTo)
- return postThreadsContainer(ctx, userID, params)
-}
-
-func publishContainer(ctx context.Context, userID, token, containerID string) (string, error) {
- params := url.Values{}
- params.Set("access_token", token)
- params.Set("creation_id", containerID)
- endpoint := graphBaseURL + "/" + userID + "/threads_publish?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
- if err != nil {
- return "", err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return "", err
- }
- if status != http.StatusOK {
- return "", parseAPIError(body, status)
- }
- var payload struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return "", err
- }
- if strings.TrimSpace(payload.ID) == "" {
- return "", fmt.Errorf("threads publish did not return media id")
- }
- return payload.ID, nil
-}
-
-func waitForContainerReady(ctx context.Context, containerID, token string, maxAttempts int) error {
- if maxAttempts <= 0 {
- maxAttempts = 12
- }
- for attempt := 0; attempt < maxAttempts; attempt++ {
- if attempt > 0 {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-time.After(2 * time.Second):
- }
- }
- params := url.Values{}
- params.Set("access_token", token)
- params.Set("fields", "status,error_message")
- endpoint := graphBaseURL + "/" + containerID + "?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return err
- }
- if status != http.StatusOK {
- return parseAPIError(body, status)
- }
- var payload struct {
- Status string `json:"status"`
- ErrorMessage string `json:"error_message"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return err
- }
- switch strings.ToUpper(strings.TrimSpace(payload.Status)) {
- case "FINISHED":
- return nil
- case "ERROR", "EXPIRED":
- detail := strings.TrimSpace(payload.ErrorMessage)
- if detail != "" {
- return fmt.Errorf("threads container %s: %s", strings.ToLower(payload.Status), detail)
- }
- return fmt.Errorf("threads container status %s", payload.Status)
- }
- }
- return fmt.Errorf("threads container not ready in time")
-}
-
-func verifyReplyTarget(ctx context.Context, replyTo, token string) error {
- replyTo = strings.TrimSpace(replyTo)
- if replyTo == "" {
- return fmt.Errorf("reply_to_id is required")
- }
- if !IsNumericMediaID(replyTo) {
- return fmt.Errorf("reply_to_id 必須是 Threads 數字 media id,目前為 %q", replyTo)
- }
- params := url.Values{}
- params.Set("access_token", token)
- params.Set("fields", "id,is_reply,permalink")
- endpoint := graphBaseURL + "/" + url.PathEscape(replyTo) + "?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return err
- }
- body, status, err := doRequest(req)
- if err != nil {
- return err
- }
- if status != http.StatusOK {
- return fmt.Errorf("無法讀取回覆目標 %s:%w", replyTo, parseAPIError(body, status))
- }
- var payload struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return fmt.Errorf("parse reply target: %w", err)
- }
- if strings.TrimSpace(payload.ID) == "" {
- return fmt.Errorf("回覆目標 %s 不存在或無權限讀取", replyTo)
- }
- return nil
-}
-
-func fetchPermalink(ctx context.Context, mediaID, token string) (string, error) {
- params := url.Values{}
- params.Set("access_token", token)
- params.Set("fields", "permalink")
- endpoint := graphBaseURL + "/" + mediaID + "?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return "", err
- }
- body, status, err := doRequest(req)
- if err != nil || status != http.StatusOK {
- return "", err
- }
- var payload struct {
- Permalink string `json:"permalink"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return "", err
- }
- return strings.TrimSpace(payload.Permalink), nil
-}
-
-func doRequest(req *http.Request) ([]byte, int, error) {
- client := &http.Client{Timeout: 25 * time.Second}
- res, err := client.Do(req)
- if err != nil {
- return nil, 0, err
- }
- defer res.Body.Close()
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return nil, res.StatusCode, err
- }
- return body, res.StatusCode, nil
-}
diff --git a/old/backend/internal/library/threadsapi/publish_media.go b/old/backend/internal/library/threadsapi/publish_media.go
deleted file mode 100644
index e620594..0000000
--- a/old/backend/internal/library/threadsapi/publish_media.go
+++ /dev/null
@@ -1,155 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/threadspost"
-)
-
-const maxCarouselItems = 20
-
-type publishMediaInput struct {
- ThreadsUserID string
- AccessToken string
- Text string
- TopicTag string
- ReplyToID string
- ImageURLs []string
-}
-
-func normalizeImageURLs(urls []string) []string {
- out := make([]string, 0, len(urls))
- seen := make(map[string]struct{}, len(urls))
- for _, raw := range urls {
- url := strings.TrimSpace(raw)
- if url == "" {
- continue
- }
- if _, ok := seen[url]; ok {
- continue
- }
- seen[url] = struct{}{}
- out = append(out, url)
- if len(out) >= maxCarouselItems {
- break
- }
- }
- return out
-}
-
-func publishMedia(ctx context.Context, in publishMediaInput) (*PublishResult, error) {
- userID := strings.TrimSpace(in.ThreadsUserID)
- token := strings.TrimSpace(in.AccessToken)
- text := strings.TrimSpace(in.Text)
- replyTo := strings.TrimSpace(in.ReplyToID)
- imageURLs := normalizeImageURLs(in.ImageURLs)
-
- if userID == "" || token == "" {
- return nil, fmt.Errorf("threads api credentials incomplete")
- }
- if replyTo == "" && text == "" && len(imageURLs) == 0 {
- return nil, fmt.Errorf("post text or image is required")
- }
- if replyTo != "" && text == "" && len(imageURLs) == 0 {
- return nil, fmt.Errorf("reply text or image is required")
- }
- if replyTo != "" && len(imageURLs) > 1 {
- return nil, fmt.Errorf("Threads API 目前不支援多圖回覆,請只附一張圖")
- }
- if replyTo != "" {
- if err := verifyReplyTarget(ctx, replyTo, token); err != nil {
- return nil, err
- }
- }
- if replyTo == "" {
- if err := threadspost.ValidatePublishContent(text, len(imageURLs) > 0); err != nil {
- return nil, err
- }
- } else if err := threadspost.ValidateReplyContent(text, len(imageURLs) > 0); err != nil {
- return nil, err
- }
-
- var containerID string
- var err error
- switch len(imageURLs) {
- case 0:
- if replyTo != "" {
- containerID, err = createReplyContainer(ctx, userID, token, replyTo, text)
- } else {
- containerID, err = createTextContainer(ctx, userID, token, text, in.TopicTag)
- }
- case 1:
- if replyTo == "" {
- containerID, err = createImageContainer(ctx, userID, token, text, imageURLs[0], in.TopicTag)
- } else {
- containerID, err = createImageReplyContainer(ctx, userID, token, replyTo, text, imageURLs[0])
- }
- default:
- containerID, err = createCarouselContainer(ctx, userID, token, text, in.TopicTag, replyTo, imageURLs)
- }
- if err != nil {
- return nil, err
- }
-
- waitAttempts := containerWaitAttempts(len(imageURLs))
- if err := waitForContainerReady(ctx, containerID, token, waitAttempts); err != nil {
- return nil, err
- }
- mediaID, err := publishContainer(ctx, userID, token, containerID)
- if err != nil {
- return nil, err
- }
- permalink, _ := fetchPermalink(ctx, mediaID, token)
- return &PublishResult{MediaID: mediaID, Permalink: permalink}, nil
-}
-
-func containerWaitAttempts(imageCount int) int {
- if imageCount == 0 {
- return 12
- }
- if imageCount == 1 {
- return 30
- }
- return 30 + imageCount*2
-}
-
-func createCarouselContainer(ctx context.Context, userID, token, text, topicTag, replyTo string, imageURLs []string) (string, error) {
- if len(imageURLs) < 2 {
- return "", fmt.Errorf("carousel requires at least two images")
- }
- childIDs := make([]string, 0, len(imageURLs))
- for _, imageURL := range imageURLs {
- childID, err := createCarouselItemContainer(ctx, userID, token, imageURL)
- if err != nil {
- return "", err
- }
- if err := waitForContainerReady(ctx, childID, token, 30); err != nil {
- return "", err
- }
- childIDs = append(childIDs, childID)
- }
-
- params := urlValues(token)
- params.Set("media_type", "CAROUSEL")
- params.Set("children", strings.Join(childIDs, ","))
- if text != "" {
- params.Set("text", text)
- }
- if tag := normalizeTopicTag(topicTag); tag != "" {
- params.Set("topic_tag", tag)
- }
- if replyTo != "" {
- params.Set("reply_to_id", replyTo)
- }
- return postThreadsContainer(ctx, userID, params)
-}
-
-func createCarouselItemContainer(ctx context.Context, userID, token, imageURL string) (string, error) {
- params := urlValues(token)
- params.Set("is_carousel_item", "true")
- params.Set("media_type", "IMAGE")
- params.Set("image_url", imageURL)
- return postThreadsContainer(ctx, userID, params)
-}
\ No newline at end of file
diff --git a/old/backend/internal/library/threadsapi/replies.go b/old/backend/internal/library/threadsapi/replies.go
deleted file mode 100644
index d058294..0000000
--- a/old/backend/internal/library/threadsapi/replies.go
+++ /dev/null
@@ -1,220 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
-)
-
-const mediaConversationFields = "id,text,username,permalink,timestamp,is_reply,is_reply_owned_by_me,has_replies,replied_to,root_post,parent_id,media_type,media_url,thumbnail_url,gif_url,children{id,media_type,media_url,thumbnail_url}"
-
-type ReplyMediaChild struct {
- ID string `json:"id"`
- MediaType string `json:"media_type"`
- MediaURL string `json:"media_url"`
- ThumbnailURL string `json:"thumbnail_url"`
-}
-
-type ReplyItem struct {
- ID string `json:"id"`
- Text string `json:"text"`
- Username string `json:"username"`
- Permalink string `json:"permalink"`
- Timestamp string `json:"timestamp"`
- LikeCount int `json:"like_count"`
- ParentID string `json:"parent_id"`
- RepliedToID string `json:"replied_to_id"`
- RootPostID string `json:"root_post_id"`
- IsReply bool `json:"is_reply"`
- IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"`
- HasReplies bool `json:"has_replies"`
- MediaType string `json:"media_type"`
- MediaURL string `json:"media_url"`
- ThumbnailURL string `json:"thumbnail_url"`
- GifURL string `json:"gif_url"`
- MediaChildren []ReplyMediaChild `json:"media_children,omitempty"`
-}
-
-type replyItemRaw struct {
- ID string `json:"id"`
- Text string `json:"text"`
- Username string `json:"username"`
- Permalink string `json:"permalink"`
- Timestamp string `json:"timestamp"`
- LikeCount int `json:"like_count"`
- ParentID string `json:"parent_id"`
- RepliedTo json.RawMessage `json:"replied_to"`
- RootPost json.RawMessage `json:"root_post"`
- IsReply bool `json:"is_reply"`
- IsReplyOwnedByMe bool `json:"is_reply_owned_by_me"`
- HasReplies bool `json:"has_replies"`
- MediaType string `json:"media_type"`
- MediaURL string `json:"media_url"`
- ThumbnailURL string `json:"thumbnail_url"`
- GifURL string `json:"gif_url"`
- Children json.RawMessage `json:"children"`
-}
-
-func (c *Client) MediaReplies(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) {
- return c.MediaConversation(ctx, mediaID, limit)
-}
-
-// MediaConversation returns flattened top-level and nested replies for a thread root.
-func (c *Client) MediaConversation(ctx context.Context, mediaID string, limit int) ([]ReplyItem, error) {
- if !c.Enabled() {
- return nil, fmt.Errorf("threads api access token is required")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return nil, fmt.Errorf("threads media id is required")
- }
- if limit <= 0 {
- limit = 10
- }
- if limit > 100 {
- limit = 100
- }
-
- out := make([]ReplyItem, 0, limit)
- nextURL := ""
- for len(out) < limit {
- pageLimit := limit - len(out)
- if pageLimit > 50 {
- pageLimit = 50
- }
- endpoint := nextURL
- if endpoint == "" {
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("fields", mediaConversationFields)
- params.Set("limit", fmt.Sprintf("%d", pageLimit))
- params.Set("reverse", "false")
- endpoint = graphBaseURL + "/" + url.PathEscape(mediaID) + "/conversation?" + params.Encode()
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return nil, err
- }
- res, err := c.http.Do(req)
- if err != nil {
- return nil, err
- }
-
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- res.Body.Close()
- if err != nil {
- return nil, err
- }
- if res.StatusCode != http.StatusOK {
- return nil, parseAPIError(body, res.StatusCode)
- }
-
- var payload struct {
- Data []replyItemRaw `json:"data"`
- Paging struct {
- Next string `json:"next"`
- } `json:"paging"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return nil, fmt.Errorf("parse threads conversation: %w", err)
- }
- for _, raw := range payload.Data {
- out = append(out, normalizeReplyItem(raw))
- if len(out) >= limit {
- break
- }
- }
- nextURL = strings.TrimSpace(payload.Paging.Next)
- if nextURL == "" || len(payload.Data) == 0 {
- break
- }
- }
- return out, nil
-}
-
-func normalizeReplyItem(raw replyItemRaw) ReplyItem {
- repliedToID := parseMediaRefID(raw.RepliedTo)
- if repliedToID == "" {
- repliedToID = strings.TrimSpace(raw.ParentID)
- }
- return ReplyItem{
- ID: strings.TrimSpace(raw.ID),
- Text: strings.TrimSpace(raw.Text),
- Username: strings.TrimSpace(raw.Username),
- Permalink: strings.TrimSpace(raw.Permalink),
- Timestamp: strings.TrimSpace(raw.Timestamp),
- LikeCount: raw.LikeCount,
- ParentID: strings.TrimSpace(raw.ParentID),
- RepliedToID: repliedToID,
- RootPostID: parseMediaRefID(raw.RootPost),
- IsReply: raw.IsReply,
- IsReplyOwnedByMe: raw.IsReplyOwnedByMe,
- HasReplies: raw.HasReplies,
- MediaType: strings.TrimSpace(raw.MediaType),
- MediaURL: strings.TrimSpace(raw.MediaURL),
- ThumbnailURL: strings.TrimSpace(raw.ThumbnailURL),
- GifURL: strings.TrimSpace(raw.GifURL),
- MediaChildren: parseMediaChildren(raw.Children),
- }
-}
-
-func parseMediaChildren(raw json.RawMessage) []ReplyMediaChild {
- if len(raw) == 0 || string(raw) == "null" {
- return nil
- }
-
- var asList []ReplyMediaChild
- if err := json.Unmarshal(raw, &asList); err == nil {
- return normalizeMediaChildren(asList)
- }
-
- var wrapped struct {
- Data []ReplyMediaChild `json:"data"`
- }
- if err := json.Unmarshal(raw, &wrapped); err == nil && len(wrapped.Data) > 0 {
- return normalizeMediaChildren(wrapped.Data)
- }
- return nil
-}
-
-func normalizeMediaChildren(items []ReplyMediaChild) []ReplyMediaChild {
- out := make([]ReplyMediaChild, 0, len(items))
- for _, item := range items {
- child := ReplyMediaChild{
- ID: strings.TrimSpace(item.ID),
- MediaType: strings.TrimSpace(item.MediaType),
- MediaURL: strings.TrimSpace(item.MediaURL),
- ThumbnailURL: strings.TrimSpace(item.ThumbnailURL),
- }
- if child.ID == "" && child.MediaURL == "" && child.ThumbnailURL == "" {
- continue
- }
- out = append(out, child)
- }
- if len(out) == 0 {
- return nil
- }
- return out
-}
-
-func parseMediaRefID(raw json.RawMessage) string {
- if len(raw) == 0 {
- return ""
- }
- var asString string
- if err := json.Unmarshal(raw, &asString); err == nil {
- return strings.TrimSpace(asString)
- }
- var asObject struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(raw, &asObject); err == nil {
- return strings.TrimSpace(asObject.ID)
- }
- return ""
-}
diff --git a/old/backend/internal/library/threadsapi/replies_test.go b/old/backend/internal/library/threadsapi/replies_test.go
deleted file mode 100644
index d123180..0000000
--- a/old/backend/internal/library/threadsapi/replies_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package threadsapi
-
-import (
- "encoding/json"
- "testing"
-)
-
-func TestParseMediaRefIDObjectAndString(t *testing.T) {
- obj := json.RawMessage(`{"id":"123"}`)
- if got := parseMediaRefID(obj); got != "123" {
- t.Fatalf("object ref: got %q", got)
- }
- str := json.RawMessage(`"456"`)
- if got := parseMediaRefID(str); got != "456" {
- t.Fatalf("string ref: got %q", got)
- }
-}
-
-func TestNormalizeReplyItemPrefersRepliedTo(t *testing.T) {
- item := normalizeReplyItem(replyItemRaw{
- ID: "child",
- ParentID: "legacy-parent",
- RepliedTo: json.RawMessage(`{"id":"real-parent"}`),
- Text: "hello",
- })
- if item.RepliedToID != "real-parent" {
- t.Fatalf("expected replied_to id, got %q", item.RepliedToID)
- }
-}
-
-func TestNormalizeReplyItemKeepsMediaURLs(t *testing.T) {
- item := normalizeReplyItem(replyItemRaw{
- ID: "img-reply",
- MediaType: "IMAGE",
- MediaURL: "https://example.com/photo.jpg",
- ThumbnailURL: "https://example.com/thumb.jpg",
- GifURL: "https://example.com/cat.gif",
- })
- if item.MediaURL != "https://example.com/photo.jpg" {
- t.Fatalf("media_url: got %q", item.MediaURL)
- }
- if item.GifURL != "https://example.com/cat.gif" {
- t.Fatalf("gif_url: got %q", item.GifURL)
- }
-}
-
-func TestNormalizeReplyItemParsesCarouselChildren(t *testing.T) {
- item := normalizeReplyItem(replyItemRaw{
- ID: "carousel-reply",
- MediaType: "CAROUSEL_ALBUM",
- Children: json.RawMessage(`{"data":[{"id":"c1","media_type":"IMAGE","media_url":"https://example.com/1.jpg"},{"id":"c2","media_type":"VIDEO","media_url":"https://example.com/2.mp4","thumbnail_url":"https://example.com/2.jpg"}]}`),
- })
- if len(item.MediaChildren) != 2 {
- t.Fatalf("expected 2 media children, got %d", len(item.MediaChildren))
- }
- if item.MediaChildren[0].MediaURL != "https://example.com/1.jpg" {
- t.Fatalf("first child media_url: got %q", item.MediaChildren[0].MediaURL)
- }
- if item.MediaChildren[1].ThumbnailURL != "https://example.com/2.jpg" {
- t.Fatalf("second child thumbnail_url: got %q", item.MediaChildren[1].ThumbnailURL)
- }
-}
-
-func TestParseMediaChildrenAcceptsArray(t *testing.T) {
- children := parseMediaChildren(json.RawMessage(`[{"id":"x","media_type":"IMAGE","media_url":"https://example.com/x.jpg"}]`))
- if len(children) != 1 || children[0].ID != "x" {
- t.Fatalf("unexpected children: %#v", children)
- }
-}
diff --git a/old/backend/internal/library/threadsapi/resolve_media.go b/old/backend/internal/library/threadsapi/resolve_media.go
deleted file mode 100644
index ab8e38a..0000000
--- a/old/backend/internal/library/threadsapi/resolve_media.go
+++ /dev/null
@@ -1,298 +0,0 @@
-package threadsapi
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "regexp"
- "strings"
- "unicode/utf8"
-)
-
-var threadsPermalinkRe = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([^/]+)/post/([^/?#]+)`)
-
-// IsNumericMediaID reports whether the value looks like a Threads Graph media id.
-func IsNumericMediaID(raw string) bool {
- raw = strings.TrimSpace(raw)
- if len(raw) < 8 {
- return false
- }
- for _, r := range raw {
- if r < '0' || r > '9' {
- return false
- }
- }
- return true
-}
-
-func parsePermalinkParts(permalink string) (username, shortcode string) {
- permalink = strings.TrimSpace(permalink)
- if permalink == "" {
- return "", ""
- }
- permalink = strings.Split(permalink, "?")[0]
- permalink = strings.Split(permalink, "#")[0]
- match := threadsPermalinkRe.FindStringSubmatch(permalink)
- if len(match) < 3 {
- return "", ""
- }
- return strings.TrimSpace(match[1]), strings.TrimSpace(match[2])
-}
-
-type ResolveReplyMediaInput struct {
- ExternalID string
- Permalink string
- HintText string
-}
-
-// ResolveReplyMediaID returns a Graph API media id suitable for reply_to_id.
-func (c *Client) ResolveReplyMediaID(ctx context.Context, externalID, permalink string) (string, error) {
- return c.ResolveReplyMedia(ctx, ResolveReplyMediaInput{
- ExternalID: externalID,
- Permalink: permalink,
- })
-}
-
-// ResolveReplyMedia resolves shortcode/permalink targets into numeric media ids for reply_to_id.
-func (c *Client) ResolveReplyMedia(ctx context.Context, in ResolveReplyMediaInput) (string, error) {
- if !c.Enabled() {
- return "", fmt.Errorf("threads api access token is required")
- }
- candidate := strings.TrimSpace(in.ExternalID)
- if IsNumericMediaID(candidate) {
- return candidate, nil
- }
- username, shortcode := parsePermalinkParts(in.Permalink)
- if !IsNumericMediaID(candidate) && candidate != "" {
- shortcode = candidate
- }
- if IsNumericMediaID(shortcode) {
- return shortcode, nil
- }
- if shortcode == "" {
- return "", fmt.Errorf("缺少有效的 Threads 貼文 ID")
- }
- if username == "" {
- return "", fmt.Errorf("permalink 缺少作者資訊,無法解析貼文 ID")
- }
-
- for _, query := range keywordQueriesFromHint(in.HintText) {
- if id, err := c.lookupMediaIDByKeywordText(ctx, username, shortcode, query); err == nil && id != "" {
- return id, nil
- }
- }
- if id, err := c.lookupMediaIDViaProfilePosts(ctx, username, shortcode); err == nil && id != "" {
- return id, nil
- }
- if id, err := c.lookupMediaIDByAuthorAndShortcode(ctx, username, shortcode); err == nil && id != "" {
- return id, nil
- }
- return "", fmt.Errorf(
- "無法將貼文 shortcode 轉成 Threads media id(%s/@%s/post/%s)。請確認 OAuth 具備 threads_profile_discovery 與 threads_keyword_search,或重新以 API 海巡抓取貼文",
- shortcode, username, shortcode,
- )
-}
-
-func keywordQueriesFromHint(hint string) []string {
- hint = strings.TrimSpace(hint)
- if hint == "" {
- return nil
- }
- hint = strings.Join(strings.Fields(hint), " ")
- seen := map[string]struct{}{}
- out := make([]string, 0, 3)
- add := func(query string) {
- query = strings.TrimSpace(query)
- if query == "" || utf8.RuneCountInString(query) < 4 {
- return
- }
- if _, ok := seen[query]; ok {
- return
- }
- seen[query] = struct{}{}
- out = append(out, query)
- }
- add(trimRunes(hint, 80))
- if idx := strings.IndexAny(hint, "。!?!?.\n"); idx > 0 {
- add(trimRunes(hint[:idx], 60))
- }
- add(trimRunes(hint, 32))
- return out
-}
-
-func trimRunes(raw string, max int) string {
- raw = strings.TrimSpace(raw)
- if max <= 0 || utf8.RuneCountInString(raw) <= max {
- return raw
- }
- runes := []rune(raw)
- return strings.TrimSpace(string(runes[:max]))
-}
-
-func (c *Client) lookupMediaIDViaProfilePosts(ctx context.Context, username, shortcode string) (string, error) {
- return c.FindMediaIDByAuthorShortcode(ctx, username, shortcode)
-}
-
-func (c *Client) lookupMediaIDByKeywordText(ctx context.Context, username, shortcode, query string) (string, error) {
- query = strings.TrimSpace(query)
- if query == "" {
- return "", fmt.Errorf("keyword query is required")
- }
- for _, searchType := range []string{"TOP", "RECENT"} {
- id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode)
- if err == nil && id != "" {
- return id, nil
- }
- }
- return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode)
-}
-
-func (c *Client) lookupMediaIDByAuthorAndShortcode(ctx context.Context, username, shortcode string) (string, error) {
- username = strings.TrimPrefix(strings.TrimSpace(username), "@")
- shortcode = strings.TrimSpace(shortcode)
- if username == "" || shortcode == "" {
- return "", fmt.Errorf("username and shortcode are required")
- }
- type lookupStrategy struct {
- searchType string
- withAuthor bool
- }
- strategies := []lookupStrategy{
- {searchType: "TOP", withAuthor: true},
- {searchType: "RECENT", withAuthor: true},
- {searchType: "TOP", withAuthor: false},
- {searchType: "RECENT", withAuthor: false},
- }
- var lastErr error
- for _, strategy := range strategies {
- id, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, strategy.searchType, strategy.withAuthor, shortcode)
- if err == nil && id != "" {
- return id, nil
- }
- if err != nil {
- lastErr = err
- }
- }
- if lastErr != nil {
- return "", lastErr
- }
- return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, shortcode)
-}
-
-func (c *Client) keywordSearchMediaByShortcode(
- ctx context.Context,
- username, query, searchType string,
- withAuthor bool,
- wantShortcode string,
-) (string, error) {
- query = strings.TrimSpace(query)
- wantShortcode = strings.TrimSpace(wantShortcode)
- if query == "" || wantShortcode == "" {
- return "", fmt.Errorf("query and shortcode are required")
- }
- params := url.Values{}
- params.Set("access_token", c.accessToken)
- params.Set("q", query)
- params.Set("search_type", searchType)
- if withAuthor {
- params.Set("author_username", username)
- }
- params.Set("fields", "id,permalink,shortcode")
- params.Set("limit", "25")
- endpoint := graphBaseURL + "/keyword_search?" + params.Encode()
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return "", err
- }
- res, err := c.http.Do(req)
- if err != nil {
- return "", err
- }
- defer res.Body.Close()
- body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
- if err != nil {
- return "", err
- }
- if res.StatusCode != http.StatusOK {
- return "", parseAPIError(body, res.StatusCode)
- }
- var payload struct {
- Data []struct {
- ID string `json:"id"`
- Permalink string `json:"permalink"`
- Shortcode string `json:"shortcode"`
- } `json:"data"`
- }
- if err := json.Unmarshal(body, &payload); err != nil {
- return "", err
- }
- wantSuffix := "/post/" + wantShortcode
- for _, item := range payload.Data {
- id := strings.TrimSpace(item.ID)
- if !IsNumericMediaID(id) {
- continue
- }
- if withAuthor {
- link := strings.TrimSpace(item.Permalink)
- if link != "" && !strings.Contains(strings.ToLower(link), "/@"+strings.ToLower(username)+"/") {
- continue
- }
- }
- if strings.EqualFold(strings.TrimSpace(item.Shortcode), wantShortcode) {
- return id, nil
- }
- link := strings.TrimSpace(item.Permalink)
- if link != "" && strings.HasSuffix(strings.Split(link, "?")[0], wantSuffix) {
- return id, nil
- }
- }
- return "", fmt.Errorf("threads keyword_search did not return media for @%s post %s", username, wantShortcode)
-}
-
-// PrimeReplyTargetForPublish registers a public post via Threads API search before reply_to_id publish.
-// Meta only allows replying to public posts that were recently returned by keyword_search.
-func (c *Client) PrimeReplyTargetForPublish(ctx context.Context, in ResolveReplyMediaInput, mediaID string) error {
- if !c.Enabled() {
- return fmt.Errorf("threads api access token is required")
- }
- username, shortcode := parsePermalinkParts(in.Permalink)
- if candidate := strings.TrimSpace(in.ExternalID); !IsNumericMediaID(candidate) && candidate != "" {
- shortcode = candidate
- }
- mediaID = strings.TrimSpace(mediaID)
- if username == "" || shortcode == "" || mediaID == "" {
- return fmt.Errorf("缺少作者、shortcode 或 media id,無法預先搜尋貼文")
- }
-
- // Fast path: shortcode / author queries with author_username filter (fewer API round-trips).
- for _, query := range []string{shortcode, username, "@" + username} {
- for _, searchType := range []string{"TOP", "RECENT"} {
- id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode)
- if err == nil && strings.TrimSpace(id) == mediaID {
- return nil
- }
- }
- }
- for _, query := range keywordQueriesFromHint(in.HintText) {
- for _, searchType := range []string{"TOP", "RECENT"} {
- id, err := c.keywordSearchMediaByShortcode(ctx, username, query, searchType, true, shortcode)
- if err == nil && strings.TrimSpace(id) == mediaID {
- return nil
- }
- }
- }
- if id, err := c.FindMediaIDByAuthorShortcode(ctx, username, shortcode); err == nil && strings.TrimSpace(id) == mediaID {
- for _, searchType := range []string{"TOP", "RECENT"} {
- if _, err := c.keywordSearchMediaByShortcode(ctx, username, shortcode, searchType, true, shortcode); err == nil {
- return nil
- }
- }
- }
- return fmt.Errorf(
- "Threads API 搜尋不到 @%s 的這篇貼文(%s)。回覆他人貼文前必須先用 keyword_search 找到它,請確認 threads_keyword_search 權限已核准,或改用「開始海巡(API)」重新抓取",
- username, shortcode,
- )
-}
diff --git a/old/backend/internal/library/threadsapi/resolve_media_hint_test.go b/old/backend/internal/library/threadsapi/resolve_media_hint_test.go
deleted file mode 100644
index 4673002..0000000
--- a/old/backend/internal/library/threadsapi/resolve_media_hint_test.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package threadsapi
-
-import "testing"
-
-func TestKeywordQueriesFromHint(t *testing.T) {
- queries := keywordQueriesFromHint("最近換季皮膚好乾,大家有推薦的保濕產品嗎?")
- if len(queries) == 0 {
- t.Fatal("expected keyword queries")
- }
- if queries[0] == "" {
- t.Fatal("first query empty")
- }
-}
diff --git a/old/backend/internal/library/threadsapi/resolve_media_test.go b/old/backend/internal/library/threadsapi/resolve_media_test.go
deleted file mode 100644
index 4a16e3d..0000000
--- a/old/backend/internal/library/threadsapi/resolve_media_test.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package threadsapi
-
-import "testing"
-
-func TestIsNumericMediaID(t *testing.T) {
- if !IsNumericMediaID("18123456789012345") {
- t.Fatal("expected numeric media id")
- }
- if IsNumericMediaID("AbCdEf123") {
- t.Fatal("shortcode should not be numeric media id")
- }
-}
-
-func TestParsePermalinkParts(t *testing.T) {
- user, code := parsePermalinkParts("https://www.threads.net/@alice/post/AbCdEf123")
- if user != "alice" || code != "AbCdEf123" {
- t.Fatalf("got %q %q", user, code)
- }
-}
diff --git a/old/backend/internal/library/threadsapi/scopes.go b/old/backend/internal/library/threadsapi/scopes.go
deleted file mode 100644
index 5e76c48..0000000
--- a/old/backend/internal/library/threadsapi/scopes.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package threadsapi
-
-import "strings"
-
-// DefaultOAuthScopes lists all Threads API permissions used by this product.
-// threads_basic is required for all API usage.
-var DefaultOAuthScopes = []string{
- "threads_basic",
- "threads_content_publish",
- "threads_delete",
- "threads_keyword_search",
- "threads_location_tagging",
- "threads_manage_insights",
- "threads_manage_mentions",
- "threads_manage_replies",
- "threads_profile_discovery",
- "threads_read_replies",
- "threads_share_to_instagram",
-}
-
-func OAuthScopeString() string {
- return strings.Join(DefaultOAuthScopes, ",")
-}
diff --git a/old/backend/internal/library/threadsapi/url.go b/old/backend/internal/library/threadsapi/url.go
deleted file mode 100644
index f863a32..0000000
--- a/old/backend/internal/library/threadsapi/url.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threadsapi
-
-import (
- "regexp"
- "strings"
-)
-
-// profileAuthorRE captures the @ segment of a Threads permalink.
-// Examples:
-//
-// https://www.threads.com/@some.user/post/CfXyZ123
-// https://www.threads.net/@some.user/p/CfXyZ123
-var profileAuthorRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`)
-
-// ProfileURLFromPermalink derives the author's profile root URL from a Threads
-// post permalink. Falls back to https://www.threads.com/@ when the
-// permalink does not contain an @ segment. Prefer threads.com over
-// threads.net because the .com host is the current canonical domain.
-func ProfileURLFromPermalink(permalink, username string) string {
- username = strings.TrimSpace(username)
- if permalink != "" {
- if match := profileAuthorRE.FindStringSubmatch(permalink); len(match) >= 2 {
- return "https://www.threads.com/@" + match[1]
- }
- }
- if username == "" {
- return ""
- }
- return "https://www.threads.com/@" + username
-}
diff --git a/old/backend/internal/library/threadspost/format.go b/old/backend/internal/library/threadspost/format.go
deleted file mode 100644
index 84debcd..0000000
--- a/old/backend/internal/library/threadspost/format.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package threadspost
-
-import (
- "strings"
-)
-
-// FormatDraftText normalizes generated post bodies without destroying the author's voice.
-func FormatDraftText(text string) string {
- text = strings.ReplaceAll(text, "\r\n", "\n")
- text = strings.ReplaceAll(text, "\r", "\n")
- text = strings.TrimSpace(text)
- if text == "" {
- return ""
- }
-
- lines := strings.Split(text, "\n")
- out := make([]string, 0, len(lines))
- blankCount := 0
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- blankCount++
- if blankCount <= 1 && len(out) > 0 {
- out = append(out, "")
- }
- continue
- }
- blankCount = 0
- out = append(out, line)
- }
- for len(out) > 0 && out[len(out)-1] == "" {
- out = out[:len(out)-1]
- }
- return ClampPublish(strings.Join(out, "\n"))
-}
diff --git a/old/backend/internal/library/threadspost/format_test.go b/old/backend/internal/library/threadspost/format_test.go
deleted file mode 100644
index 6485dd9..0000000
--- a/old/backend/internal/library/threadspost/format_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package threadspost
-
-import "testing"
-
-func TestFormatDraftTextPreservesPunctuationAndLineBreaks(t *testing.T) {
- raw := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?"
- want := "我最近迷上自己做早餐耶!\n以前都隨便買個麵包、三明治,趕著出門。\n有沒有人也跟我一樣?"
- got := FormatDraftText(raw)
- if got != want {
- t.Fatalf("FormatDraftText() = %q, want %q", got, want)
- }
-}
-
-func TestFormatDraftTextKeepsSingleParagraph(t *testing.T) {
- raw := "第一句很好。第二句也不錯!第三句收尾?"
- want := "第一句很好。第二句也不錯!第三句收尾?"
- got := FormatDraftText(raw)
- if got != want {
- t.Fatalf("FormatDraftText() = %q, want %q", got, want)
- }
-}
-
-func TestFormatDraftTextCollapsesExcessBlankLines(t *testing.T) {
- raw := "第一段\n\n\n第二段"
- want := "第一段\n\n第二段"
- got := FormatDraftText(raw)
- if got != want {
- t.Fatalf("FormatDraftText() = %q, want %q", got, want)
- }
-}
-
-func TestFormatDraftTextPreservesEmoji(t *testing.T) {
- raw := "廚房像打完仗一樣 😂\n但看到成果就值得了"
- want := "廚房像打完仗一樣 😂\n但看到成果就值得了"
- got := FormatDraftText(raw)
- if got != want {
- t.Fatalf("FormatDraftText() = %q, want %q", got, want)
- }
-}
-
-func TestFormatDraftTextPreservesHashtag(t *testing.T) {
- raw := "今天心情不錯\n#早餐日記"
- want := "今天心情不錯\n#早餐日記"
- got := FormatDraftText(raw)
- if got != want {
- t.Fatalf("FormatDraftText() = %q, want %q", got, want)
- }
-}
diff --git a/old/backend/internal/library/threadspost/limits.go b/old/backend/internal/library/threadspost/limits.go
deleted file mode 100644
index 33424d6..0000000
--- a/old/backend/internal/library/threadspost/limits.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package threadspost
-
-import (
- "fmt"
- "strings"
-)
-
-// Threads Graph API text post hard limit (main post body).
-const MaxPublishRunes = 500
-
-// Threads reply/comment limit (outreach, not main post).
-const MaxReplyRunes = 1000
-
-// ViralSweetMin/Max: engagement sweet spot on Threads feed (Buffer / creator data, 2024–2026).
-const ViralSweetMin = 80
-const ViralSweetMax = 220
-
-// ViralSoftWarn: engagement tends to drop beyond this length in the main visible post.
-const ViralSoftWarn = 300
-
-type LengthBand string
-
-const (
- BandEmpty LengthBand = "empty"
- BandTooShort LengthBand = "too_short"
- BandSweet LengthBand = "sweet"
- BandLong LengthBand = "long"
- BandOverSoft LengthBand = "over_soft"
- BandOverHard LengthBand = "over_hard"
-)
-
-func RuneLen(text string) int {
- return len([]rune(strings.TrimSpace(text)))
-}
-
-func ClampPublish(text string) string {
- text = strings.TrimSpace(text)
- runes := []rune(text)
- if len(runes) > MaxPublishRunes {
- return string(runes[:MaxPublishRunes])
- }
- return text
-}
-
-func PublishBand(text string) LengthBand {
- n := RuneLen(text)
- switch {
- case n == 0:
- return BandEmpty
- case n < ViralSweetMin:
- return BandTooShort
- case n <= ViralSweetMax:
- return BandSweet
- case n <= ViralSoftWarn:
- return BandLong
- case n <= MaxPublishRunes:
- return BandOverSoft
- default:
- return BandOverHard
- }
-}
-
-func ValidatePublishContent(text string, hasImage bool) error {
- if strings.TrimSpace(text) == "" && !hasImage {
- return fmt.Errorf("貼文需有文字或圖片")
- }
- if strings.TrimSpace(text) == "" {
- return nil
- }
- return ValidatePublish(text)
-}
-
-func ValidateReplyContent(text string, hasImage bool) error {
- if strings.TrimSpace(text) == "" && !hasImage {
- return fmt.Errorf("回覆需有文字或圖片")
- }
- if strings.TrimSpace(text) == "" {
- return nil
- }
- return ValidateReply(text)
-}
-
-func ValidatePublish(text string) error {
- if strings.TrimSpace(text) == "" {
- return fmt.Errorf("貼文內文不可為空")
- }
- n := RuneLen(text)
- if n > MaxPublishRunes {
- return fmt.Errorf("貼文超過 Threads 上限 %d 字(目前 %d 字)", MaxPublishRunes, n)
- }
- return nil
-}
-
-func ValidateReply(text string) error {
- if strings.TrimSpace(text) == "" {
- return fmt.Errorf("留言內文不可為空")
- }
- n := RuneLen(text)
- if n > MaxReplyRunes {
- return fmt.Errorf("留言超過 Threads 上限 %d 字(目前 %d 字)", MaxReplyRunes, n)
- }
- return nil
-}
-
-func PublishHint(text string) string {
- switch PublishBand(text) {
- case BandTooShort:
- return fmt.Sprintf("偏短(爆款常見 %d~%d 字)", ViralSweetMin, ViralSweetMax)
- case BandSweet:
- return fmt.Sprintf("長度適中(爆款常見 %d~%d 字)", ViralSweetMin, ViralSweetMax)
- case BandLong:
- return fmt.Sprintf("略長(超過 %d 字互動可能下降)", ViralSoftWarn)
- case BandOverSoft:
- return fmt.Sprintf("接近 Threads 主文上限 %d 字", MaxPublishRunes)
- case BandOverHard:
- return fmt.Sprintf("超過 Threads 上限 %d 字,請縮短", MaxPublishRunes)
- default:
- return ""
- }
-}
diff --git a/old/backend/internal/library/threadspost/limits_test.go b/old/backend/internal/library/threadspost/limits_test.go
deleted file mode 100644
index 629b98e..0000000
--- a/old/backend/internal/library/threadspost/limits_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package threadspost
-
-import "testing"
-
-func TestClampPublish(t *testing.T) {
- var long string
- for range MaxPublishRunes + 10 {
- long += "字"
- }
- clamped := ClampPublish(long)
- if RuneLen(clamped) != MaxPublishRunes {
- t.Fatalf("expected %d runes, got %d", MaxPublishRunes, RuneLen(clamped))
- }
-}
-
-func TestValidatePublish(t *testing.T) {
- if err := ValidatePublish(""); err == nil {
- t.Fatal("expected empty error")
- }
- if err := ValidatePublish(string(make([]rune, MaxPublishRunes+1))); err == nil {
- t.Fatal("expected over-limit error")
- }
- if err := ValidatePublish("爆款貼文"); err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-}
-
-func TestPublishBand(t *testing.T) {
- cases := []struct {
- len int
- want LengthBand
- }{
- {0, BandEmpty},
- {50, BandTooShort},
- {120, BandSweet},
- {250, BandLong},
- {450, BandOverSoft},
- {501, BandOverHard},
- }
- for _, c := range cases {
- text := ""
- for range c.len {
- text += "字"
- }
- if got := PublishBand(text); got != c.want {
- t.Fatalf("len %d: got %s want %s", c.len, got, c.want)
- }
- }
-}
diff --git a/old/backend/internal/library/threadspost/mimic.go b/old/backend/internal/library/threadspost/mimic.go
deleted file mode 100644
index b78bd9d..0000000
--- a/old/backend/internal/library/threadspost/mimic.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package threadspost
-
-import (
- "fmt"
- "strings"
-)
-
-// LineCount returns non-empty lines after normalizing newlines.
-func LineCount(text string) int {
- text = strings.ReplaceAll(text, "\r\n", "\n")
- text = strings.ReplaceAll(text, "\r", "\n")
- n := 0
- for _, line := range strings.Split(text, "\n") {
- if strings.TrimSpace(line) != "" {
- n++
- }
- }
- return n
-}
-
-// MimicLengthRange returns a suggested min/max rune count when imitating referenceText.
-func MimicLengthRange(referenceText string) (min, max int) {
- ref := RuneLen(referenceText)
- if ref <= 0 {
- return ViralSweetMin, ViralSweetMax
- }
- min = int(float64(ref) * 0.75)
- max = int(float64(ref) * 1.25)
- if min < 20 {
- min = 20
- }
- if max < min+10 {
- max = min + 10
- }
- if max > MaxPublishRunes {
- max = MaxPublishRunes
- }
- if min > max {
- min = max
- }
- return min, max
-}
-
-// MimicLengthGuidance builds prompt text for rewrite/mimic flows.
-func MimicLengthGuidance(referenceText string) string {
- referenceText = strings.TrimSpace(referenceText)
- if referenceText == "" {
- return ""
- }
- refRunes := RuneLen(referenceText)
- refLines := LineCount(referenceText)
- min, max := MimicLengthRange(referenceText)
- lineMin, lineMax := refLines, refLines
- if refLines > 1 {
- lineMin = refLines - 1
- if lineMin < 1 {
- lineMin = 1
- }
- lineMax = refLines + 1
- }
- return fmt.Sprintf(
- "【篇幅(仿寫)】參考原文約 %d 字、%d 行。產出正文請控制在 %d~%d 字、約 %d~%d 行,不要明顯比參考長很多或短很多。精簡優先:刪掉重複鋪陳、無關細節、雞湯總結與多餘轉折;每一句都要推進故事或情緒,不然就砍掉。",
- refRunes, refLines, min, max, lineMin, lineMax,
- )
-}
diff --git a/old/backend/internal/library/threadspost/mimic_test.go b/old/backend/internal/library/threadspost/mimic_test.go
deleted file mode 100644
index 08fcffc..0000000
--- a/old/backend/internal/library/threadspost/mimic_test.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package threadspost
-
-import (
- "strings"
- "testing"
-)
-
-func TestMimicLengthRange(t *testing.T) {
- min, max := MimicLengthRange("今天早餐吃了蛋餅\n加辣真的爽")
- if min <= 0 || max < min {
- t.Fatalf("MimicLengthRange() = %d, %d", min, max)
- }
-}
-
-func TestMimicLengthGuidanceIncludesReferenceStats(t *testing.T) {
- ref := "第一句\n第二句\n第三句"
- got := MimicLengthGuidance(ref)
- if got == "" {
- t.Fatal("MimicLengthGuidance() empty")
- }
- for _, part := range []string{"3 行", "精簡優先"} {
- if !strings.Contains(got, part) {
- t.Fatalf("MimicLengthGuidance() missing %q: %q", part, got)
- }
- }
-}
diff --git a/old/backend/internal/library/validate/validate.go b/old/backend/internal/library/validate/validate.go
deleted file mode 100644
index 5ccf19d..0000000
--- a/old/backend/internal/library/validate/validate.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package validate
-
-import "github.com/go-playground/validator/v10"
-
-type Validate struct {
- validator *validator.Validate
-}
-
-func New() *Validate {
- return &Validate{validator: validator.New()}
-}
-
-func (v *Validate) ValidateAll(value any) error {
- if v == nil || v.validator == nil {
- return nil
- }
- return v.validator.Struct(value)
-}
diff --git a/old/backend/internal/library/viral/analyze_viral.go b/old/backend/internal/library/viral/analyze_viral.go
deleted file mode 100644
index 45f1cdf..0000000
--- a/old/backend/internal/library/viral/analyze_viral.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package viral
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-)
-
-type ViralAnalysis struct {
- StoryHook string `json:"storyHook"`
- NarrativeBeat string `json:"narrativeBeat"`
- EmotionalCore string `json:"emotionalCore"`
- SceneSeed string `json:"sceneSeed"`
- AuthenticityCues []string `json:"authenticityCues"`
- WhatToAvoid string `json:"whatToAvoid"`
-
- // Legacy fields kept for backward-compatible parsing.
- HookPattern string `json:"hookPattern"`
- StructurePattern string `json:"structurePattern"`
- EmotionalTrigger string `json:"emotionalTrigger"`
- ReplicationStrategy string `json:"replicationStrategy"`
- KeyTakeaways []string `json:"keyTakeaways"`
-}
-
-type AnalyzeViralInput struct {
- PostText string
- AuthorName string
- LikeCount int
- ReplyCount int
- SearchTag string
- TopicLabel string
- TopicBrief string
- Persona string
-}
-
-func BuildAnalyzeViralSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 敘事分析師。拆解這篇貼文為什麼讓人想看完、怎麼寫出同樣的故事感(不是抄句型)。
-
-只回傳 JSON(繁體中文):
-- storyHook:讓人停下滑動的開場力量(描述手法,不要給可照抄的句子)
-- narrativeBeat:故事怎麼推進(經歷/轉折/感受,不是段落模板)
-- emotionalCore:核心情緒是什麼
-- sceneSeed:一個值得借鑑的「具體場景種子」(時間/地點/動作,供啟發勿照搬)
-- authenticityCues:3~5 個讓它像真人的小細節(口語、坦白、不完美、具體細節等)
-- whatToAvoid:若照搬哪些寫法會立刻變成 AI 複製文`)
-}
-
-func BuildAnalyzeViralUserPrompt(in AnalyzeViralInput) string {
- var b strings.Builder
- b.WriteString("主題:")
- b.WriteString(strings.TrimSpace(in.TopicLabel))
- b.WriteString("\n")
- if brief := strings.TrimSpace(in.TopicBrief); brief != "" {
- b.WriteString("Brief:")
- b.WriteString(brief)
- b.WriteString("\n")
- }
- if tag := strings.TrimSpace(in.SearchTag); tag != "" {
- b.WriteString("搜尋標籤:")
- b.WriteString(tag)
- b.WriteString("\n")
- }
- author := strings.TrimSpace(in.AuthorName)
- if author == "" {
- author = "匿名"
- }
- b.WriteString(fmt.Sprintf("\n作者 @%s · %d 讚 · %d 留言\n", author, in.LikeCount, in.ReplyCount))
- b.WriteString("\n貼文:\n")
- b.WriteString(strings.TrimSpace(in.PostText))
- return b.String()
-}
-
-func ParseAnalyzeViralOutput(raw string) (ViralAnalysis, error) {
- payload, err := extractCopyMapJSON(raw)
- if err != nil {
- return ViralAnalysis{}, err
- }
- var out ViralAnalysis
- if err := json.Unmarshal(payload, &out); err != nil {
- return ViralAnalysis{}, fmt.Errorf("parse viral analysis: %w", err)
- }
- if strings.TrimSpace(out.StoryHook) == "" &&
- strings.TrimSpace(out.NarrativeBeat) == "" &&
- strings.TrimSpace(out.HookPattern) == "" &&
- strings.TrimSpace(out.StructurePattern) == "" {
- return ViralAnalysis{}, fmt.Errorf("viral analysis missing content")
- }
- return out, nil
-}
-
-func FormatAnalysisForReplicate(analysis ViralAnalysis) string {
- var b strings.Builder
- if hook := firstNonEmpty(analysis.StoryHook, analysis.HookPattern); hook != "" {
- b.WriteString("故事開場力量:")
- b.WriteString(hook)
- b.WriteString("\n")
- }
- if beat := firstNonEmpty(analysis.NarrativeBeat, analysis.StructurePattern); beat != "" {
- b.WriteString("敘事推進:")
- b.WriteString(beat)
- b.WriteString("\n")
- }
- if emotion := firstNonEmpty(analysis.EmotionalCore, analysis.EmotionalTrigger); emotion != "" {
- b.WriteString("核心情緒:")
- b.WriteString(emotion)
- b.WriteString("\n")
- }
- if scene := strings.TrimSpace(analysis.SceneSeed); scene != "" {
- b.WriteString("場景種子(啟發用,勿照搬):")
- b.WriteString(scene)
- b.WriteString("\n")
- }
- cues := analysis.AuthenticityCues
- if len(cues) == 0 {
- cues = analysis.KeyTakeaways
- }
- if len(cues) > 0 {
- b.WriteString("真人質感線索:")
- b.WriteString(strings.Join(cues, ";"))
- b.WriteString("\n")
- }
- if avoid := firstNonEmpty(analysis.WhatToAvoid, analysis.ReplicationStrategy); avoid != "" {
- b.WriteString("避免變成複製文:")
- b.WriteString(avoid)
- }
- return strings.TrimSpace(b.String())
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if trimmed := strings.TrimSpace(value); trimmed != "" {
- return trimmed
- }
- }
- return ""
-}
diff --git a/old/backend/internal/library/viral/analyze_viral_test.go b/old/backend/internal/library/viral/analyze_viral_test.go
deleted file mode 100644
index 8a5af82..0000000
--- a/old/backend/internal/library/viral/analyze_viral_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package viral
-
-import "testing"
-
-func TestFormatAnalysisForReplicateUsesNarrativeFields(t *testing.T) {
- got := FormatAnalysisForReplicate(ViralAnalysis{
- StoryHook: "先丟一個具體窘況",
- NarrativeBeat: "從失敗經驗拐到頓悟",
- EmotionalCore: "鬆了一口氣",
- SceneSeed: "週日早晨的廚房",
- AuthenticityCues: []string{"坦白搞砸", "口語碎碎念"},
- WhatToAvoid: "不要照抄三段式",
- })
- for _, part := range []string{"故事開場力量", "敘事推進", "核心情緒", "場景種子", "真人質感線索", "避免變成複製文", "週日早晨的廚房"} {
- if got == "" || !contains(got, part) {
- t.Fatalf("FormatAnalysisForReplicate() missing %q: %q", part, got)
- }
- }
-}
-
-func TestFormatAnalysisForReplicateFallsBackToLegacyFields(t *testing.T) {
- got := FormatAnalysisForReplicate(ViralAnalysis{
- HookPattern: "痛點開場",
- StructurePattern: "三段式",
- KeyTakeaways: []string{"具體細節"},
- })
- if !contains(got, "故事開場力量") || !contains(got, "痛點開場") {
- t.Fatalf("legacy fallback missing: %q", got)
- }
-}
-
-func contains(s, part string) bool {
- return len(s) >= len(part) && (s == part || len(part) == 0 || indexOf(s, part) >= 0)
-}
-
-func indexOf(s, part string) int {
- for i := 0; i+len(part) <= len(s); i++ {
- if s[i:i+len(part)] == part {
- return i
- }
- }
- return -1
-}
diff --git a/old/backend/internal/library/viral/audience.go b/old/backend/internal/library/viral/audience.go
deleted file mode 100644
index 59c9e25..0000000
--- a/old/backend/internal/library/viral/audience.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package viral
-
-import (
- "sort"
- "strings"
- "unicode/utf8"
-
- "haixun-backend/internal/library/placement"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
-)
-
-const MaxAudienceSamples = 15
-
-type AudienceOpts struct {
- Max int
- ExcludeAuthors []string
- Now int64
-}
-
-type audienceAgg struct {
- username string
- samplePostID string
- sampleText string
- replyLikeCount int
- appearances int
- lastPostedAt string
-}
-
-// BuildAudienceSamplesFromReplies aggregates reply authors into potential
-// audience samples. Low-quality replies and known similar-account authors are
-// filtered out.
-func BuildAudienceSamplesFromReplies(replies []placement.ReplyCandidate, opts AudienceOpts) []missionentity.AudienceSample {
- if len(replies) == 0 {
- return nil
- }
- max := opts.Max
- if max <= 0 {
- max = MaxAudienceSamples
- }
- exclude := map[string]struct{}{}
- for _, author := range opts.ExcludeAuthors {
- key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(author, "@")))
- if key != "" {
- exclude[key] = struct{}{}
- }
- }
-
- byUser := map[string]audienceAgg{}
- for _, reply := range replies {
- user := strings.TrimSpace(reply.Author)
- if user == "" || !isValidUsername(user) {
- continue
- }
- if _, ok := exclude[strings.ToLower(user)]; ok {
- continue
- }
- text := strings.TrimSpace(reply.Text)
- if isLowQualityReply(text) {
- continue
- }
- key := strings.ToLower(user)
- prev := byUser[key]
- prev.username = user
- prev.appearances++
- if reply.LikeCount > prev.replyLikeCount {
- prev.replyLikeCount = reply.LikeCount
- prev.sampleText = text
- prev.samplePostID = strings.TrimSpace(reply.ExternalID)
- prev.lastPostedAt = strings.TrimSpace(reply.PostedAt)
- } else if prev.sampleText == "" {
- prev.sampleText = text
- prev.samplePostID = strings.TrimSpace(reply.ExternalID)
- prev.lastPostedAt = strings.TrimSpace(reply.PostedAt)
- }
- byUser[key] = prev
- }
-
- ranked := make([]audienceAgg, 0, len(byUser))
- for _, item := range byUser {
- ranked = append(ranked, item)
- }
- sort.Slice(ranked, func(i, j int) bool {
- if ranked[i].appearances != ranked[j].appearances {
- return ranked[i].appearances > ranked[j].appearances
- }
- if ranked[i].replyLikeCount != ranked[j].replyLikeCount {
- return ranked[i].replyLikeCount > ranked[j].replyLikeCount
- }
- return ranked[i].lastPostedAt > ranked[j].lastPostedAt
- })
- if len(ranked) > max {
- ranked = ranked[:max]
- }
-
- now := opts.Now
- out := make([]missionentity.AudienceSample, 0, len(ranked))
- for _, item := range ranked {
- out = append(out, missionentity.AudienceSample{
- Username: item.username,
- SamplePostID: item.samplePostID,
- SampleText: item.sampleText,
- ReplyLikeCount: item.replyLikeCount,
- Appearances: item.appearances,
- FirstSeenAt: now,
- LastSeenAt: now,
- })
- }
- return out
-}
-
-// MergeAudienceSamples merges previous and newly built audience samples keyed
-// by lowercase username. New samples refresh statistics; pinned samples are
-// preserved even when not seen in the latest scan.
-func MergeAudienceSamples(prev, next []missionentity.AudienceSample) []missionentity.AudienceSample {
- if len(prev) == 0 {
- return append([]missionentity.AudienceSample(nil), next...)
- }
- if len(next) == 0 {
- return prioritizePinnedAudienceSamples(append([]missionentity.AudienceSample(nil), prev...))
- }
-
- byUser := map[string]missionentity.AudienceSample{}
- newOrder := make([]string, 0, len(next))
- for _, item := range next {
- key := strings.ToLower(strings.TrimSpace(item.Username))
- if key == "" {
- continue
- }
- if _, ok := byUser[key]; !ok {
- newOrder = append(newOrder, key)
- }
- byUser[key] = mergeAudienceRecord(byUser[key], item)
- }
-
- prevOrder := make([]string, 0, len(prev))
- for _, item := range prev {
- key := strings.ToLower(strings.TrimSpace(item.Username))
- if key == "" {
- continue
- }
- if _, ok := byUser[key]; ok {
- merged := mergeAudienceRecord(item, byUser[key])
- byUser[key] = merged
- continue
- }
- byUser[key] = item
- prevOrder = append(prevOrder, key)
- }
-
- out := make([]missionentity.AudienceSample, 0, len(newOrder)+len(prevOrder))
- for _, key := range newOrder {
- if sample, ok := byUser[key]; ok {
- out = append(out, sample)
- }
- }
- for _, key := range prevOrder {
- if sample, ok := byUser[key]; ok {
- out = append(out, sample)
- }
- }
- return prioritizePinnedAudienceSamples(out)
-}
-
-func mergeAudienceRecord(prev, next missionentity.AudienceSample) missionentity.AudienceSample {
- out := next
- if prev.FirstSeenAt > 0 {
- out.FirstSeenAt = prev.FirstSeenAt
- }
- if prev.Status == missionentity.AudienceSampleStatusPinned || prev.Status == missionentity.AudienceSampleStatusExcluded {
- out.Status = prev.Status
- }
- if out.LastSeenAt == 0 {
- out.LastSeenAt = prev.LastSeenAt
- }
- return out
-}
-
-func prioritizePinnedAudienceSamples(samples []missionentity.AudienceSample) []missionentity.AudienceSample {
- if len(samples) == 0 {
- return nil
- }
- pinned := make([]missionentity.AudienceSample, 0)
- rest := make([]missionentity.AudienceSample, 0, len(samples))
- for _, item := range samples {
- if item.Status == missionentity.AudienceSampleStatusPinned {
- pinned = append(pinned, item)
- } else {
- rest = append(rest, item)
- }
- }
- out := append(pinned, rest...)
- if len(out) > MaxAudienceSamples {
- remaining := MaxAudienceSamples - len(pinned)
- if remaining < 0 {
- return pinned[:MaxAudienceSamples]
- }
- out = append(pinned, rest[:remaining]...)
- }
- return out
-}
-
-func isLowQualityReply(text string) bool {
- trimmed := strings.TrimSpace(text)
- if trimmed == "" {
- return true
- }
- if utf8.RuneCountInString(trimmed) < 5 {
- return true
- }
- lower := strings.ToLower(trimmed)
- if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
- fields := strings.Fields(trimmed)
- if len(fields) <= 1 {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/viral/discover.go b/old/backend/internal/library/viral/discover.go
deleted file mode 100644
index d1645d4..0000000
--- a/old/backend/internal/library/viral/discover.go
+++ /dev/null
@@ -1,240 +0,0 @@
-package viral
-
-import (
- "context"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/placement"
-)
-
-const (
- defaultLimitPerKeyword = 15
- missionLimitPerKeyword = 10
- maxKeywords = 6
- maxMergedPosts = 60
- missionMaxMergedPosts = 40
- missionQualityTarget = 12 // stop scanning extra keywords once enough quality posts
-)
-
-type DiscoverInput struct {
- Keywords []string
- Exclusions []string
- SeedQuery string
- Label string
- TopicHints []string
- Member placement.MemberContext
- Crawler placement.CrawlerSearchFn
- Limit int // per keyword; 0 = default
- MaxMerged int // total cap; 0 = default
- MissionScan bool // leaner defaults to save search API quota
-}
-
-type ProgressFn func(message string, pct int)
-
-// RunDiscover searches Threads for viral candidates across keywords, ranked by engagement.
-func RunDiscover(ctx context.Context, input DiscoverInput, progress ProgressFn) ([]placement.ScanCandidate, error) {
- keywords := normalizeKeywords(input.Keywords)
- if len(keywords) == 0 {
- return nil, fmt.Errorf("請提供至少一個爆款掃描關鍵字")
- }
- perKeyword := input.Limit
- if perKeyword <= 0 {
- if input.MissionScan {
- perKeyword = missionLimitPerKeyword
- } else {
- perKeyword = defaultLimitPerKeyword
- }
- }
- maxMerged := input.MaxMerged
- if maxMerged <= 0 {
- if input.MissionScan {
- maxMerged = missionMaxMergedPosts
- } else {
- maxMerged = maxMergedPosts
- }
- }
-
- merged := map[string]placement.ScanCandidate{}
- relaxed := map[string]placement.ScanCandidate{}
- total := len(keywords)
- pathLabel := input.Member.DiscoverPathLabel()
- topicTerms := missionTopicMatchTerms(input.SeedQuery, input.Label, input.TopicHints)
- var lastErr error
- keywordsAttempted := 0
-
- for i, keyword := range keywords {
- if input.MissionScan && countMissionQuality(merged) >= missionQualityTarget {
- if progress != nil {
- progress(fmt.Sprintf("已收足 %d 篇品質候選,略過剩餘標籤以節省搜尋次數", missionQualityTarget), 10+(i*70)/max(total, 1))
- }
- break
- }
- if progress != nil {
- pct := 10 + (i*70)/total
- progress(fmt.Sprintf("掃描「%s」(%s)…", keyword, pathLabel), pct)
- }
- limit := perKeyword
- if input.MissionScan && len(merged) > 0 {
- limit = min(perKeyword, 8)
- }
- posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
- Query: keyword,
- Keyword: keyword,
- Limit: limit,
- Member: input.Member,
- Crawler: input.Crawler,
- })
- if err != nil {
- lastErr = err
- if progress != nil {
- progress(fmt.Sprintf("「%s」搜尋略過:%s", keyword, shortenDiscoverErr(err)), 10+(i*70)/max(total, 1))
- }
- continue
- }
- keywordsAttempted++
- for _, post := range posts {
- key := strings.TrimSpace(post.Permalink)
- if key == "" {
- key = strings.TrimSpace(post.ExternalID)
- }
- if key == "" {
- continue
- }
- score := ScorePost(post.LikeCount, post.ReplyCount)
- candidate := placement.ScanCandidate{
- Permalink: post.Permalink,
- ExternalID: post.ExternalID,
- Author: post.Author,
- AuthorVerified: post.AuthorVerified,
- FollowerCount: post.FollowerCount,
- Text: post.Text,
- SearchTag: keyword,
- Source: post.Source,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- EngagementScore: score,
- PlacementScore: score,
- Priority: PriorityLabel(score),
- }
- if input.MissionScan {
- if !missionPostMatchesTopic(candidate, topicTerms) {
- continue
- }
- if PassesMissionQualityCandidate(
- post.Text, post.LikeCount, post.ReplyCount, score,
- post.AuthorVerified, post.FollowerCount, input.Exclusions,
- ) {
- mergeCandidate(merged, key, candidate)
- continue
- }
- if PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
- mergeCandidate(relaxed, key, candidate)
- }
- continue
- }
- if !PassesViralCandidate(post.Text, post.LikeCount, post.ReplyCount, score, input.Exclusions) {
- continue
- }
- mergeCandidate(merged, key, candidate)
- }
- }
-
- if input.MissionScan && len(merged) == 0 && len(relaxed) > 0 {
- merged = relaxed
- if progress != nil {
- progress("未取得藍勾等延伸資料,改以互動門檻收斂爆款候選", 82)
- }
- }
-
- out := candidatesFromMap(merged)
- sortByEngagement(out)
- if len(out) > maxMerged {
- out = out[:maxMerged]
- }
- if len(out) == 0 {
- if keywordsAttempted == 0 && lastErr != nil {
- return nil, fmt.Errorf("所有標籤搜尋均失敗:%w", lastErr)
- }
- }
- if progress != nil {
- progress(fmt.Sprintf("合併 %d 篇爆款候選", len(out)), 85)
- }
- return out, nil
-}
-
-func mergeCandidate(merged map[string]placement.ScanCandidate, key string, candidate placement.ScanCandidate) {
- if prev, ok := merged[key]; !ok {
- merged[key] = candidate
- } else if candidate.EngagementScore > prev.EngagementScore {
- merged[key] = MergeAuthorSignals(candidate, prev)
- } else {
- merged[key] = MergeAuthorSignals(prev, candidate)
- }
-}
-
-func candidatesFromMap(merged map[string]placement.ScanCandidate) []placement.ScanCandidate {
- out := make([]placement.ScanCandidate, 0, len(merged))
- for _, item := range merged {
- out = append(out, item)
- }
- return out
-}
-
-func shortenDiscoverErr(err error) string {
- msg := strings.TrimSpace(err.Error())
- if len(msg) > 80 {
- return msg[:80] + "…"
- }
- return msg
-}
-
-func normalizeKeywords(raw []string) []string {
- seen := map[string]struct{}{}
- out := make([]string, 0, len(raw))
- for _, item := range raw {
- kw := DiscoverKeywordFromTag(item)
- if kw == "" {
- continue
- }
- if _, ok := seen[kw]; ok {
- continue
- }
- seen[kw] = struct{}{}
- out = append(out, kw)
- if len(out) >= maxKeywords {
- break
- }
- }
- return out
-}
-
-func countMissionQuality(merged map[string]placement.ScanCandidate) int {
- n := 0
- for _, item := range merged {
- if PassesMissionQualityCandidate(
- item.Text, item.LikeCount, item.ReplyCount, item.EngagementScore,
- item.AuthorVerified, item.FollowerCount, nil,
- ) {
- n++
- }
- }
- return n
-}
-
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
-
-func sortByEngagement(items []placement.ScanCandidate) {
- for i := 0; i < len(items); i++ {
- for j := i + 1; j < len(items); j++ {
- if items[j].EngagementScore > items[i].EngagementScore {
- items[i], items[j] = items[j], items[i]
- }
- }
- }
-}
diff --git a/old/backend/internal/library/viral/discover_accounts.go b/old/backend/internal/library/viral/discover_accounts.go
deleted file mode 100644
index 28277e8..0000000
--- a/old/backend/internal/library/viral/discover_accounts.go
+++ /dev/null
@@ -1,209 +0,0 @@
-package viral
-
-import (
- "context"
- "regexp"
- "sort"
- "strings"
-
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/websearch"
-)
-
-const (
- // Single web-search query per supplement pass — fewer API calls, same recall
- // via a combined site: + seed + brief/pillar hint in one request.
- maxAccountDiscoverQueries = 1
- MaxSimilarAccounts = 10
- // Skip web-search supplement when scan already surfaced enough reference authors.
- MinSimilarAccountsBeforeWebSupplement = 5
-)
-
-var threadsProfileRE = regexp.MustCompile(`(?i)threads\.(?:com|net)/@([a-zA-Z0-9._]+)`)
-
-var reservedUsernames = map[string]struct{}{
- "login": {}, "signup": {}, "search": {}, "explore": {}, "home": {},
- "help": {}, "about": {}, "privacy": {}, "terms": {}, "settings": {},
- "threads": {}, "thread": {}, "instagram": {}, "meta": {}, "www": {},
-}
-
-type SimilarAccount struct {
- Username string `json:"username"`
- Reason string `json:"reason"`
- Source string `json:"source"`
- MatchedSource []string `json:"matchedSource,omitempty"`
- Confidence string `json:"confidence"`
- ProfileURL string `json:"profileUrl"`
-}
-
-type DiscoverAccountsInput struct {
- SeedQuery string
- Brief string
- Pillars []string
-}
-
-type accountCandidate struct {
- username string
- score int
- reason string
- source string
- permalink string
-}
-
-func DiscoverSimilarAccounts(ctx context.Context, client websearch.Client, input DiscoverAccountsInput) ([]SimilarAccount, error) {
- if client == nil || !client.Enabled() {
- return nil, nil
- }
- seed := strings.TrimSpace(input.SeedQuery)
- if seed == "" {
- return nil, nil
- }
- queries := buildAccountDiscoverQueries(seed, input.Brief, input.Pillars)
- if len(queries) == 0 {
- return nil, nil
- }
-
- seen := map[string]accountCandidate{}
- for _, query := range queries {
- res, err := client.Search(ctx, websearch.SearchOptions{
- Query: query,
- Limit: 12,
- Mode: websearch.ModeThreadsDiscover,
- })
- if err != nil || res.Status != "success" {
- continue
- }
- for _, item := range res.Results {
- blob := strings.TrimSpace(item.URL + " " + item.Title + " " + item.Snippet)
- for _, username := range extractUsernames(blob) {
- weight := 2
- if strings.Contains(strings.ToLower(item.URL), "/@"+strings.ToLower(username)) {
- weight = 4
- }
- reason := strings.TrimSpace(item.Snippet)
- if reason == "" {
- reason = strings.TrimSpace(item.Title)
- }
- if reason == "" {
- reason = "在「" + seed + "」相關搜尋結果中找到"
- }
- if len([]rune(reason)) > 120 {
- reason = string([]rune(reason)[:120])
- }
- key := strings.ToLower(username)
- prev, ok := seen[key]
- permalink := strings.TrimSpace(item.URL)
- if !ok || weight > prev.score {
- seen[key] = accountCandidate{
- username: username,
- score: weight,
- reason: reason,
- source: "web",
- permalink: permalink,
- }
- } else if ok {
- prev.score += 1
- if prev.permalink == "" && permalink != "" {
- prev.permalink = permalink
- }
- seen[key] = prev
- }
- }
- }
- }
-
- out := make([]accountCandidate, 0, len(seen))
- for _, item := range seen {
- out = append(out, item)
- }
- sort.Slice(out, func(i, j int) bool { return out[i].score > out[j].score })
- if len(out) > MaxSimilarAccounts {
- out = out[:MaxSimilarAccounts]
- }
-
- accounts := make([]SimilarAccount, 0, len(out))
- for _, item := range out {
- profileURL := libthreads.ProfileURLFromPermalink(item.permalink, item.username)
- accounts = append(accounts, SimilarAccount{
- Username: item.username,
- Reason: item.reason,
- Source: item.source,
- MatchedSource: []string{item.source},
- Confidence: accountConfidence(item.score),
- ProfileURL: profileURL,
- })
- }
- return accounts, nil
-}
-
-func buildAccountDiscoverQueries(seed, brief string, pillars []string) []string {
- seed = strings.TrimSpace(seed)
- if seed == "" {
- return nil
- }
- quoted := `"` + seed + `"`
- query := `site:threads.net ` + quoted
- if hint := strings.TrimSpace(brief); len([]rune(hint)) >= 4 && len([]rune(hint)) <= 24 {
- query += ` ` + hint
- }
- for _, pillar := range pillars {
- pillar = strings.TrimSpace(pillar)
- if len([]rune(pillar)) >= 4 {
- query += ` "` + pillar + `"`
- break
- }
- }
- query = strings.TrimSpace(query)
- if query == "" {
- return nil
- }
- return []string{query}
-}
-
-func extractUsernames(blob string) []string {
- matches := threadsProfileRE.FindAllStringSubmatch(blob, -1)
- out := []string{}
- seen := map[string]struct{}{}
- for _, match := range matches {
- if len(match) < 2 {
- continue
- }
- user := strings.TrimSpace(match[1])
- if !isValidUsername(user) {
- continue
- }
- key := strings.ToLower(user)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, user)
- }
- return out
-}
-
-func isValidUsername(username string) bool {
- if username == "" || len(username) < 2 || len(username) > 30 {
- return false
- }
- if _, ok := reservedUsernames[strings.ToLower(username)]; ok {
- return false
- }
- for _, r := range username {
- if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' {
- continue
- }
- return false
- }
- return true
-}
-
-func accountConfidence(score int) string {
- if score >= 5 {
- return "high"
- }
- if score >= 3 {
- return "medium"
- }
- return "low"
-}
diff --git a/old/backend/internal/library/viral/discover_accounts_test.go b/old/backend/internal/library/viral/discover_accounts_test.go
deleted file mode 100644
index e114ea7..0000000
--- a/old/backend/internal/library/viral/discover_accounts_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package viral
-
-import (
- "strings"
- "testing"
-)
-
-func TestExtractUsernames(t *testing.T) {
- blob := `See https://www.threads.net/@creator_one and threads.com/@creator_two/posts/abc`
- got := extractUsernames(blob)
- if len(got) != 2 {
- t.Fatalf("expected 2 usernames, got %d (%v)", len(got), got)
- }
-}
-
-func TestIsValidUsernameRejectsReserved(t *testing.T) {
- if isValidUsername("login") {
- t.Fatal("reserved username should be rejected")
- }
- if !isValidUsername("real_creator") {
- t.Fatal("valid username rejected")
- }
-}
-
-func TestBuildAccountDiscoverQueriesSingleCombinedQuery(t *testing.T) {
- queries := buildAccountDiscoverQueries("轉職", "想找語錄", []string{"職場語錄", "支柱B"})
- if len(queries) != 1 {
- t.Fatalf("expected 1 combined query, got %d (%v)", len(queries), queries)
- }
- if !strings.Contains(queries[0], `site:threads.net`) || !strings.Contains(queries[0], `"轉職"`) {
- t.Fatalf("unexpected query: %q", queries[0])
- }
- if !strings.Contains(queries[0], "想找語錄") || !strings.Contains(queries[0], `"職場語錄"`) {
- t.Fatalf("brief/pillar hint missing from combined query: %q", queries[0])
- }
-}
diff --git a/old/backend/internal/library/viral/discover_budget_test.go b/old/backend/internal/library/viral/discover_budget_test.go
deleted file mode 100644
index 0755cad..0000000
--- a/old/backend/internal/library/viral/discover_budget_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package viral
-
-import (
- "testing"
-
- "haixun-backend/internal/library/placement"
-)
-
-func TestCountMissionQuality(t *testing.T) {
- merged := map[string]placement.ScanCandidate{
- "a": {Text: "轉職技巧分享", LikeCount: 25, ReplyCount: 4, EngagementScore: 65},
- "b": {Text: "轉職", LikeCount: 3, ReplyCount: 0, EngagementScore: 8},
- }
- if got := countMissionQuality(merged); got != 1 {
- t.Fatalf("expected 1 quality post, got %d", got)
- }
-}
diff --git a/old/backend/internal/library/viral/discover_graceful_test.go b/old/backend/internal/library/viral/discover_graceful_test.go
deleted file mode 100644
index f406237..0000000
--- a/old/backend/internal/library/viral/discover_graceful_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package viral
-
-import (
- "context"
- "errors"
- "testing"
-
- "haixun-backend/internal/library/placement"
-)
-
-func TestRunDiscover_skipsFailedKeyword(t *testing.T) {
- calls := 0
- crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
- calls++
- if keyword == "bad" {
- return nil, errors.New("api timeout")
- }
- return []placement.DiscoverPost{{
- Text: "轉職面試技巧分享心得", Author: "ok_user", LikeCount: 30, ReplyCount: 4,
- Permalink: "https://www.threads.net/@ok_user/post/1", Source: placement.DiscoverCrawler,
- }}, nil
- }
- member := placement.MemberContext{
- AllowsCrawler: true,
- BrowserConnected: true,
- SearchSourceMode: placement.SearchSourceCrawler,
- }
- out, err := RunDiscover(context.Background(), DiscoverInput{
- Keywords: []string{"bad", "轉職"},
- Member: member,
- Crawler: crawler,
- MissionScan: true,
- }, nil)
- if err != nil {
- t.Fatalf("expected partial success, got err: %v", err)
- }
- if len(out) != 1 {
- t.Fatalf("expected 1 candidate, got %d", len(out))
- }
- if calls != 2 {
- t.Fatalf("expected 2 keyword attempts, got %d", calls)
- }
-}
-
-func TestRunDiscover_missionRelaxedFallbackWithoutVerified(t *testing.T) {
- crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
- return []placement.DiscoverPost{{
- Text: "轉職心得分享", Author: "plain_user", LikeCount: 12, ReplyCount: 2,
- Permalink: "https://www.threads.net/@plain_user/post/2", Source: placement.DiscoverThreadsAPI,
- }}, nil
- }
- member := placement.MemberContext{
- AllowsCrawler: true,
- BrowserConnected: true,
- SearchSourceMode: placement.SearchSourceCrawler,
- }
- out, err := RunDiscover(context.Background(), DiscoverInput{
- Keywords: []string{"轉職"},
- Member: member,
- Crawler: crawler,
- MissionScan: true,
- }, nil)
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- if len(out) != 1 {
- t.Fatalf("expected relaxed fallback candidate, got %d", len(out))
- }
- if out[0].AuthorVerified {
- t.Fatal("verified should remain false when API omits it")
- }
-}
-
-func TestRunDiscover_missionFiltersOffTopicCrawlerDrift(t *testing.T) {
- crawler := func(ctx context.Context, m placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
- return []placement.DiscoverPost{
- {
- Text: "毛小孩飲食心得分享,最近很多人問罐罐怎麼挑",
- Author: "pet_user",
- LikeCount: 200,
- ReplyCount: 40,
- Permalink: "https://www.threads.net/@pet_user/post/pet",
- Source: placement.DiscoverCrawler,
- },
- {
- Text: "備孕精蟲品質心得分享:作息、壓力和檢查報告怎麼一起看",
- Author: "fertility_user",
- LikeCount: 26,
- ReplyCount: 6,
- Permalink: "https://www.threads.net/@fertility_user/post/quality",
- Source: placement.DiscoverCrawler,
- },
- }, nil
- }
- member := placement.MemberContext{
- AllowsCrawler: true,
- BrowserConnected: true,
- SearchSourceMode: placement.SearchSourceCrawler,
- }
- out, err := RunDiscover(context.Background(), DiscoverInput{
- Keywords: []string{"精蟲品質"},
- SeedQuery: "精蟲品質",
- Label: "備孕內容",
- TopicHints: []string{"備孕", "精蟲品質"},
- Member: member,
- Crawler: crawler,
- MissionScan: true,
- }, nil)
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- if len(out) != 1 {
- t.Fatalf("expected only topic-matched candidate, got %d", len(out))
- }
- if out[0].Author != "fertility_user" {
- t.Fatalf("expected fertility candidate, got %q", out[0].Author)
- }
-}
diff --git a/old/backend/internal/library/viral/enrich_accounts.go b/old/backend/internal/library/viral/enrich_accounts.go
deleted file mode 100644
index 4fa00ed..0000000
--- a/old/backend/internal/library/viral/enrich_accounts.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package viral
-
-import (
- "sort"
- "strings"
-
- "haixun-backend/internal/library/placement"
- libthreads "haixun-backend/internal/library/threadsapi"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
-)
-
-func EnrichSimilarAccounts(existing []missionentity.SimilarAccount, posts []placement.ScanCandidate, limit int) []missionentity.SimilarAccount {
- if limit <= 0 {
- limit = MaxSimilarAccounts
- }
- byUser := map[string]missionentity.SimilarAccount{}
- order := []string{}
- for _, item := range existing {
- user := strings.ToLower(strings.TrimSpace(item.Username))
- if user == "" {
- continue
- }
- if _, ok := byUser[user]; !ok {
- order = append(order, user)
- }
- byUser[user] = item
- }
-
- type authorScore struct {
- username string
- score int
- text string
- permalink string
- }
- authors := map[string]authorScore{}
- for _, post := range posts {
- user := strings.TrimSpace(post.Author)
- if user == "" || !isValidUsername(user) {
- continue
- }
- key := strings.ToLower(user)
- prev := authors[key]
- prev.username = user
- prev.score += post.EngagementScore
- if prev.text == "" && strings.TrimSpace(post.Text) != "" {
- prev.text = strings.TrimSpace(post.Text)
- }
- if prev.permalink == "" {
- prev.permalink = strings.TrimSpace(post.Permalink)
- }
- authors[key] = prev
- }
- ranked := make([]authorScore, 0, len(authors))
- for _, item := range authors {
- ranked = append(ranked, item)
- }
- sort.Slice(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
-
- for _, item := range ranked {
- key := strings.ToLower(item.username)
- if _, ok := byUser[key]; ok {
- continue
- }
- reason := "本次海巡高互動作者"
- if item.text != "" {
- runes := []rune(item.text)
- if len(runes) > 80 {
- reason = string(runes[:80])
- } else {
- reason = item.text
- }
- }
- conf := "medium"
- if item.score >= 200 {
- conf = "high"
- }
- byUser[key] = missionentity.SimilarAccount{
- Username: item.username,
- Reason: reason,
- Source: "scan",
- MatchedSource: []string{"scan"},
- Confidence: conf,
- ProfileURL: libthreads.ProfileURLFromPermalink(item.permalink, item.username),
- }
- order = append(order, key)
- }
-
- out := make([]missionentity.SimilarAccount, 0, len(order))
- for _, key := range order {
- if acc, ok := byUser[key]; ok {
- out = append(out, acc)
- }
- }
- if len(out) > limit {
- out = out[:limit]
- }
- return out
-}
-
-func AccountTagsFromSimilar(accounts []missionentity.SimilarAccount, max int) []SuggestedTag {
- if max <= 0 {
- max = 2
- }
- out := []SuggestedTag{}
- for _, acc := range accounts {
- user := strings.TrimSpace(acc.Username)
- if user == "" {
- continue
- }
- out = append(out, SuggestedTag{
- Tag: "@" + user,
- Reason: acc.Reason,
- SearchType: "帳號",
- })
- if len(out) >= max {
- break
- }
- }
- return out
-}
-
-func ToEntitySimilarAccounts(items []SimilarAccount) []missionentity.SimilarAccount {
- out := make([]missionentity.SimilarAccount, 0, len(items))
- for _, item := range items {
- matched := item.MatchedSource
- if len(matched) == 0 && item.Source != "" {
- matched = []string{item.Source}
- }
- out = append(out, missionentity.SimilarAccount{
- Username: item.Username,
- Reason: item.Reason,
- Source: item.Source,
- MatchedSource: matched,
- Confidence: item.Confidence,
- ProfileURL: item.ProfileURL,
- })
- }
- return out
-}
diff --git a/old/backend/internal/library/viral/flexible_json.go b/old/backend/internal/library/viral/flexible_json.go
deleted file mode 100644
index 78d5d74..0000000
--- a/old/backend/internal/library/viral/flexible_json.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package viral
-
-import (
- "encoding/json"
- "strings"
-)
-
-func firstJSONString(obj map[string]json.RawMessage, keys ...string) string {
- for _, key := range keys {
- raw, ok := obj[key]
- if !ok {
- continue
- }
- var value string
- if err := json.Unmarshal(raw, &value); err == nil {
- if trimmed := strings.TrimSpace(value); trimmed != "" {
- return trimmed
- }
- }
- }
- return ""
-}
-
-func flexibleStringFromItem(raw json.RawMessage) string {
- if len(raw) == 0 {
- return ""
- }
- var value string
- if err := json.Unmarshal(raw, &value); err == nil {
- return strings.TrimSpace(value)
- }
- var obj map[string]json.RawMessage
- if err := json.Unmarshal(raw, &obj); err != nil {
- return ""
- }
- if line := firstJSONString(obj, "title", "name", "label", "pillar", "tag", "text", "summary", "description", "value", "content"); line != "" {
- return line
- }
- parts := []string{}
- for _, key := range []string{"title", "name", "label"} {
- if part := firstJSONString(obj, key); part != "" {
- parts = append(parts, part)
- break
- }
- }
- if detail := firstJSONString(obj, "description", "summary", "detail", "reason"); detail != "" {
- parts = append(parts, detail)
- }
- return strings.TrimSpace(strings.Join(parts, ":"))
-}
-
-func parseFlexibleStringList(raw json.RawMessage) []string {
- if len(raw) == 0 || string(raw) == "null" {
- return nil
- }
- var items []string
- if err := json.Unmarshal(raw, &items); err == nil {
- return cleanLines(items)
- }
- var single string
- if err := json.Unmarshal(raw, &single); err == nil && strings.TrimSpace(single) != "" {
- return cleanLines([]string{single})
- }
- var arr []json.RawMessage
- if err := json.Unmarshal(raw, &arr); err != nil {
- return nil
- }
- out := make([]string, 0, len(arr))
- for _, item := range arr {
- if line := flexibleStringFromItem(item); line != "" {
- out = append(out, line)
- }
- }
- return cleanLines(out)
-}
-
-func parseFlexibleSuggestedTags(raw json.RawMessage) []SuggestedTag {
- if len(raw) == 0 || string(raw) == "null" {
- return nil
- }
- var tags []SuggestedTag
- if err := json.Unmarshal(raw, &tags); err == nil && len(tags) > 0 {
- return cleanSuggestedTags(tags)
- }
- var strs []string
- if err := json.Unmarshal(raw, &strs); err == nil {
- out := make([]SuggestedTag, 0, len(strs))
- for _, item := range strs {
- item = strings.TrimSpace(item)
- if item != "" {
- out = append(out, SuggestedTag{Tag: item})
- }
- }
- return cleanSuggestedTags(out)
- }
- var arr []json.RawMessage
- if err := json.Unmarshal(raw, &arr); err != nil {
- return nil
- }
- out := make([]SuggestedTag, 0, len(arr))
- for _, item := range arr {
- if tag := decodeSuggestedTagItem(item); tag.Tag != "" {
- out = append(out, tag)
- }
- }
- return cleanSuggestedTags(out)
-}
-
-func decodeSuggestedTagItem(raw json.RawMessage) SuggestedTag {
- var tag SuggestedTag
- if err := json.Unmarshal(raw, &tag); err == nil && strings.TrimSpace(tag.Tag) != "" {
- return tag
- }
- var snake struct {
- Tag string `json:"tag"`
- Reason string `json:"reason"`
- SearchIntent string `json:"search_intent"`
- SearchType string `json:"search_type"`
- }
- if err := json.Unmarshal(raw, &snake); err == nil && strings.TrimSpace(snake.Tag) != "" {
- return SuggestedTag{
- Tag: strings.TrimSpace(snake.Tag),
- Reason: strings.TrimSpace(snake.Reason),
- SearchIntent: strings.TrimSpace(snake.SearchIntent),
- SearchType: strings.TrimSpace(snake.SearchType),
- }
- }
- if line := flexibleStringFromItem(raw); line != "" {
- return SuggestedTag{Tag: line}
- }
- return SuggestedTag{}
-}
-
-func pickRawMessage(root map[string]json.RawMessage, keys ...string) json.RawMessage {
- for _, key := range keys {
- if raw, ok := root[key]; ok && len(raw) > 0 && string(raw) != "null" {
- return raw
- }
- }
- return nil
-}
diff --git a/old/backend/internal/library/viral/free_trends.go b/old/backend/internal/library/viral/free_trends.go
deleted file mode 100644
index 4771ea0..0000000
--- a/old/backend/internal/library/viral/free_trends.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package viral
-
-import (
- "context"
- "encoding/xml"
- "html"
- "net/http"
- "net/url"
- "regexp"
- "strings"
- "time"
-)
-
-var rssTagRE = regexp.MustCompile(`<[^>]+>`)
-
-type freeRSS struct {
- Channel struct {
- Items []freeRSSItem `xml:"item"`
- } `xml:"channel"`
-}
-
-type freeRSSItem struct {
- Title string `xml:"title"`
- Description string `xml:"description"`
- Link string `xml:"link"`
-}
-
-func CollectFreeTopicTrends(ctx context.Context, keyword, personaBrief string) []MissionInspireTrendSnippet {
- queries := freeTrendFeedQueries(keyword, personaBrief)
- client := &http.Client{Timeout: 7 * time.Second}
- out := make([]MissionInspireTrendSnippet, 0, 12)
- seen := map[string]struct{}{}
-
- add := func(query, title, snippet, link string) {
- title = cleanRSSField(title)
- snippet = cleanRSSField(snippet)
- link = strings.TrimSpace(link)
- if title == "" && snippet == "" {
- return
- }
- key := strings.ToLower(title + "|" + snippet)
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- out = append(out, MissionInspireTrendSnippet{Query: query, Title: title, Snippet: snippet, URL: link})
- }
-
- for _, feed := range queries {
- if len(out) >= 12 {
- break
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.url, nil)
- if err != nil {
- continue
- }
- req.Header.Set("User-Agent", "HaixunTopicBot/1.0")
- resp, err := client.Do(req)
- if err != nil || resp == nil {
- continue
- }
- func() {
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return
- }
- var parsed freeRSS
- if err := xml.NewDecoder(resp.Body).Decode(&parsed); err != nil {
- return
- }
- for _, item := range parsed.Channel.Items {
- if len(out) >= 12 {
- break
- }
- add(feed.label, item.Title, item.Description, item.Link)
- }
- }()
- }
- return out
-}
-
-type freeTrendFeed struct {
- label string
- url string
-}
-
-func freeTrendFeedQueries(keyword, personaBrief string) []freeTrendFeed {
- feeds := []freeTrendFeed{
- {label: "Google Trends 台灣每日趨勢", url: "https://trends.google.com/trends/trendingsearches/daily/rss?geo=TW"},
- {label: "Google Trends 台灣即時趨勢", url: "https://trends.google.com/trends/trendingsearches/realtime/rss?geo=TW&category=all"},
- }
- terms := []string{strings.TrimSpace(keyword)}
- brief := strings.TrimSpace(personaBrief)
- if brief != "" {
- if len([]rune(brief)) > 18 {
- brief = string([]rune(brief)[:18])
- }
- terms = append(terms, brief)
- }
- for _, term := range terms {
- if term == "" {
- continue
- }
- q := url.QueryEscape(term + " 共鳴 OR 痛點 OR 趨勢")
- feeds = append(feeds, freeTrendFeed{
- label: "Google News 搜尋:" + term,
- url: "https://news.google.com/rss/search?q=" + q + "&hl=zh-TW&gl=TW&ceid=TW:zh-Hant",
- })
- }
- return feeds
-}
-
-func cleanRSSField(raw string) string {
- raw = html.UnescapeString(raw)
- raw = rssTagRE.ReplaceAllString(raw, " ")
- raw = strings.Join(strings.Fields(raw), " ")
- if len([]rune(raw)) > 220 {
- return string([]rune(raw)[:220]) + "…"
- }
- return raw
-}
diff --git a/old/backend/internal/library/viral/known_accounts.go b/old/backend/internal/library/viral/known_accounts.go
deleted file mode 100644
index d4b8795..0000000
--- a/old/backend/internal/library/viral/known_accounts.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package viral
-
-import (
- "strings"
-
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- accountentity "haixun-backend/internal/model/threads_account/domain/entity"
-)
-
-// ExcludedKnownAccountUsernames returns usernames marked excluded in cross-mission memory.
-func ExcludedKnownAccountUsernames(known map[string]accountentity.KnownAccountProfile) []string {
- if len(known) == 0 {
- return nil
- }
- out := make([]string, 0)
- seen := map[string]struct{}{}
- for key, item := range known {
- if item.Status != accountentity.KnownAccountStatusExcluded {
- continue
- }
- user := strings.TrimSpace(key)
- if user == "" {
- continue
- }
- lower := strings.ToLower(user)
- if _, ok := seen[lower]; ok {
- continue
- }
- seen[lower] = struct{}{}
- out = append(out, user)
- }
- return out
-}
-
-// ApplyKnownAccountMemory applies cross-mission memory to freshly merged similar
-// accounts: inherit excluded status and boost confidence for repeat sightings.
-func ApplyKnownAccountMemory(
- accounts []missionentity.SimilarAccount,
- known map[string]accountentity.KnownAccountProfile,
-) []missionentity.SimilarAccount {
- if len(accounts) == 0 || len(known) == 0 {
- return accounts
- }
- out := make([]missionentity.SimilarAccount, len(accounts))
- copy(out, accounts)
- for i := range out {
- key := strings.ToLower(strings.TrimSpace(out[i].Username))
- if key == "" {
- continue
- }
- profile, ok := known[key]
- if !ok {
- continue
- }
- if profile.Status == accountentity.KnownAccountStatusExcluded {
- out[i].Status = missionentity.SimilarAccountStatusExcluded
- }
- if len(profile.Missions) >= 2 || profile.SeenCount >= 2 {
- if out[i].Confidence != "high" {
- out[i].Confidence = "high"
- }
- }
- }
- return out
-}
-
-// MergeKnownAccountsFromScan upserts similar-account sightings into the account-level
-// known_accounts map.
-func MergeKnownAccountsFromScan(
- prev map[string]accountentity.KnownAccountProfile,
- accounts []missionentity.SimilarAccount,
- missionID, personaID string,
- tags []string,
- now int64,
-) map[string]accountentity.KnownAccountProfile {
- out := map[string]accountentity.KnownAccountProfile{}
- for key, item := range prev {
- out[key] = item
- }
- for _, acc := range accounts {
- user := strings.TrimSpace(acc.Username)
- if user == "" {
- continue
- }
- key := strings.ToLower(user)
- profile := out[key]
- if profile.FirstSeenAt == 0 {
- profile.FirstSeenAt = now
- }
- profile.LastSeenAt = now
- profile.Missions = appendUnique(profile.Missions, missionID)
- profile.PersonaIds = appendUnique(profile.PersonaIds, personaID)
- for _, tag := range tags {
- profile.Tags = appendUnique(profile.Tags, tag)
- }
- prevCount := profile.SeenCount
- if prevCount < 0 {
- prevCount = 0
- }
- eng := float64(acc.EngagementScore)
- if prevCount == 0 {
- profile.AvgEngagement = eng
- } else {
- profile.AvgEngagement = (profile.AvgEngagement*float64(prevCount) + eng) / float64(prevCount+1)
- }
- profile.SeenCount = prevCount + 1
- switch strings.TrimSpace(acc.Status) {
- case missionentity.SimilarAccountStatusExcluded:
- profile.Status = accountentity.KnownAccountStatusExcluded
- case missionentity.SimilarAccountStatusRecommended:
- if profile.Status == accountentity.KnownAccountStatusExcluded {
- profile.Status = ""
- }
- }
- out[key] = profile
- }
- return out
-}
-
-func appendUnique(items []string, value string) []string {
- value = strings.TrimSpace(value)
- if value == "" {
- return items
- }
- for _, item := range items {
- if item == value {
- return items
- }
- }
- return append(items, value)
-}
diff --git a/old/backend/internal/library/viral/merge_accounts.go b/old/backend/internal/library/viral/merge_accounts.go
deleted file mode 100644
index 14e4170..0000000
--- a/old/backend/internal/library/viral/merge_accounts.go
+++ /dev/null
@@ -1,190 +0,0 @@
-package viral
-
-import (
- "sort"
- "strings"
-
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
-)
-
-// MergeSimilarAccounts merges previous and newly discovered similar accounts
-// keyed by lowercase username. Newly discovered accounts overwrite per-account
-// statistics, but pinned/excluded status and previously seen accounts are
-// preserved. Pinned accounts stay at the top even when not refreshed.
-func MergeSimilarAccounts(prev, next []missionentity.SimilarAccount) []missionentity.SimilarAccount {
- if len(prev) == 0 {
- return capSimilarAccounts(append([]missionentity.SimilarAccount(nil), next...))
- }
- if len(next) == 0 {
- return prioritizePinnedSimilarAccounts(append([]missionentity.SimilarAccount(nil), prev...))
- }
-
- byUser := map[string]missionentity.SimilarAccount{}
- newOrder := make([]string, 0, len(next))
- for _, item := range next {
- key := strings.ToLower(strings.TrimSpace(item.Username))
- if key == "" {
- continue
- }
- if _, ok := byUser[key]; !ok {
- newOrder = append(newOrder, key)
- }
- byUser[key] = mergeAccountRecord(byUser[key], item)
- }
-
- prevOrder := make([]string, 0, len(prev))
- for _, item := range prev {
- key := strings.ToLower(strings.TrimSpace(item.Username))
- if key == "" {
- continue
- }
- if _, ok := byUser[key]; ok {
- byUser[key] = mergeAccountRecord(item, byUser[key])
- continue
- }
- byUser[key] = item
- prevOrder = append(prevOrder, key)
- }
-
- out := make([]missionentity.SimilarAccount, 0, len(newOrder)+len(prevOrder))
- for _, key := range newOrder {
- if acc, ok := byUser[key]; ok {
- out = append(out, acc)
- }
- }
- for _, key := range prevOrder {
- if acc, ok := byUser[key]; ok {
- out = append(out, acc)
- }
- }
- return prioritizePinnedSimilarAccounts(out)
-}
-
-func mergeAccountRecord(prev, next missionentity.SimilarAccount) missionentity.SimilarAccount {
- out := next
- out.MatchedSource = unionMatchedSource(prev.MatchedSource, next.MatchedSource)
- switch prev.Status {
- case missionentity.SimilarAccountStatusPinned, missionentity.SimilarAccountStatusExcluded:
- out.Status = prev.Status
- default:
- if strings.TrimSpace(out.Status) == "" {
- out.Status = missionentity.SimilarAccountStatusRecommended
- }
- }
- return out
-}
-
-func prioritizePinnedSimilarAccounts(accounts []missionentity.SimilarAccount) []missionentity.SimilarAccount {
- if len(accounts) == 0 {
- return nil
- }
- pinned := make([]missionentity.SimilarAccount, 0)
- rest := make([]missionentity.SimilarAccount, 0, len(accounts))
- for _, item := range accounts {
- if item.Status == missionentity.SimilarAccountStatusPinned {
- pinned = append(pinned, item)
- } else {
- rest = append(rest, item)
- }
- }
- return capSimilarAccounts(append(pinned, rest...))
-}
-
-func capSimilarAccounts(accounts []missionentity.SimilarAccount) []missionentity.SimilarAccount {
- if len(accounts) <= MaxSimilarAccounts {
- return accounts
- }
- pinned := make([]missionentity.SimilarAccount, 0)
- rest := make([]missionentity.SimilarAccount, 0, len(accounts))
- for _, item := range accounts {
- if item.Status == missionentity.SimilarAccountStatusPinned {
- pinned = append(pinned, item)
- } else {
- rest = append(rest, item)
- }
- }
- remaining := MaxSimilarAccounts - len(pinned)
- if remaining < 0 {
- return pinned[:MaxSimilarAccounts]
- }
- if len(rest) > remaining {
- rest = rest[:remaining]
- }
- return append(pinned, rest...)
-}
-
-func unionMatchedSource(a, b []string) []string {
- if len(a) == 0 && len(b) == 0 {
- return nil
- }
- seen := map[string]struct{}{}
- out := make([]string, 0, len(a)+len(b))
- for _, item := range append(append([]string{}, a...), b...) {
- s := strings.TrimSpace(item)
- if s == "" {
- continue
- }
- if _, ok := seen[s]; ok {
- continue
- }
- seen[s] = struct{}{}
- out = append(out, s)
- }
- sort.Strings(out)
- if len(out) == 0 {
- return nil
- }
- return out
-}
-
-func ExcludedSimilarAccountUsernames(accounts []missionentity.SimilarAccount) []string {
- out := make([]string, 0)
- seen := map[string]struct{}{}
- for _, item := range accounts {
- if item.Status != missionentity.SimilarAccountStatusExcluded {
- continue
- }
- user := strings.TrimSpace(item.Username)
- if user == "" {
- continue
- }
- key := strings.ToLower(user)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, user)
- }
- return out
-}
-
-func SimilarAccountUsernames(accounts []missionentity.SimilarAccount) []string {
- out := make([]string, 0, len(accounts))
- seen := map[string]struct{}{}
- for _, item := range accounts {
- user := strings.TrimSpace(item.Username)
- if user == "" {
- continue
- }
- key := strings.ToLower(user)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, user)
- }
- return out
-}
-
-func isExcluded(excluded []string, username string) bool {
- user := strings.ToLower(strings.TrimSpace(username))
- if user == "" {
- return false
- }
- for _, item := range excluded {
- if strings.ToLower(strings.TrimSpace(item)) == user {
- return true
- }
- }
- return false
-}
diff --git a/old/backend/internal/library/viral/mission_inspire.go b/old/backend/internal/library/viral/mission_inspire.go
deleted file mode 100644
index 17d3884..0000000
--- a/old/backend/internal/library/viral/mission_inspire.go
+++ /dev/null
@@ -1,409 +0,0 @@
-package viral
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "sort"
- "strings"
-
- "haixun-backend/internal/library/placement"
-)
-
-type MissionInspireInput struct {
- PersonaDisplayName string
- PersonaBrief string
- PersonaBlock string
- StyleBenchmark string
- PersonaAudience string
- PersonaContentGoal string
- PersonaQuestions []string
- PersonaPillars []string
- RecentMissionLabels []string
- RecentSeedQueries []string
- AvoidTopics []string
- ContentDirection string
- UserKeyword string
- TrendSnippets []MissionInspireTrendSnippet
- WebSearchProvider string
- LLMOnly bool
-}
-
-type MissionInspireTrendSnippet struct {
- Query string
- Title string
- Snippet string
- URL string
-}
-
-type MissionInspireThreadsInput struct {
- Keyword string
- PersonaBrief string
- StyleBenchmark string
- Pillars []string
- Questions []string
- Member placement.MemberContext
- Crawler placement.CrawlerSearchFn
-}
-
-type MissionInspireOutput struct {
- Label string
- SeedQuery string
- Brief string
- TrendReason string
- TrendKeywords []string
- Angles []string
- Mission string
- TargetAudience string
- OpeningType string
- BodyType string
- Emotion string
- CtaType string
- RiskLevel string
- Avoid []string
-}
-
-func BuildMissionInspireSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 帳號的內容編輯與 Topic Radar。根據近期 Threads / 社群討論訊號、網路搜尋素材與創作者人設,先產出「可執行的內容計畫」,不要直接寫貼文正文。
-
-規則:
-1. 若【近期趨勢訊號】有內容,從中挑一個適合在 Threads 海巡的方向;優先使用社群熱門關鍵字、受眾求助、心得討論、留言常見問題,不要把新聞事件本身當主題
-2. 若趨勢訊號為空(未連線網路搜尋),**必須**改依人設、受眾痛點與常見 Threads 討論型態推測「近期可能被搜尋」的話題,並在 trendReason 說明推測理由(不要假裝有外部熱搜來源)
-3. label:任務名稱,6~18 字,像企劃案標題,不要標點堆疊
-4. seedQuery:種子關鍵字/近期熱詞,2~6 個詞用頓號或空格分隔,適合當 Threads 搜尋起點
-5. brief:這次內容計畫摘要,2~4 句,說明話題、受眾痛點、為何適合這個帳號
-6. trendReason:1~2 句,為何選這個趨勢(說明它和受眾討論、搜尋意圖或痛點的關係,不要貼 URL)
-7. trendKeywords:3~6 個相關熱詞(字串陣列),必須像 Threads 會搜尋/討論的關鍵字,不要像新聞標題
-8. angles:3~5 個可勾選切角(字串陣列),每個切角都要說明「這篇要怎麼切」,不要直接寫成完整貼文
-9. mission:內容任務,只能是「日常陪伴」「專業知識」「情緒共鳴」「工具清單」「熱門觀點」「互動提問」之一,除非使用者方向要求更精準
-10. targetAudience:這篇真正要對誰說,越具體越好
-11. openingType:開頭策略,例如「問題開場」「場景開場」「反直覺開場」「經驗開場」「清單開場」
-12. bodyType:中段結構,例如「先共鳴再整理」「問題拆解」「步驟清單」「案例對照」「觀點辯證」
-13. emotion:情緒槓桿,例如「焦慮被接住」「想被理解」「怕踩雷」「想準備好」「想被鼓勵」
-14. ctaType:互動/收藏/私訊等 CTA 型態,不要硬業配
-15. riskLevel:low / medium / high;醫療、財務、法律、政策、名人爭議等至少 medium
-16. avoid:3~6 個禁忌或不要寫偏的方向(字串陣列)
-17. 避開【近期已做過的任務】相同題材
-18. 起號優先:題目要能連續延伸 5~10 篇,不要只是一則熱搜心得
-19. 除非使用者關鍵字、人設或趨勢訊號明確要求,不要產出早餐、早安、日常飲食、純生活流水帳這類泛題
-20. 人設介紹只用來判斷語氣與受眾,不是題材庫。不可因人設寫 Y2K、咖啡、穿搭、音樂,就把靈感主題漂移成咖啡、穿搭或復古日常
-21. 若使用者有指定關鍵字,label、seedQuery、brief 都必須保留該方向,不可漂移成無關日常題
-22. 繁體中文;只回傳 JSON:
-23. 避免新聞導向:不要產出「某事件最新進展」「政策新聞懶人包」「名人/品牌新聞」這類題目;除非它已經轉化成受眾正在問的生活問題或經驗討論
-{"label":"","seedQuery":"","brief":"","trendReason":"","trendKeywords":[],"angles":[],"mission":"","targetAudience":"","openingType":"","bodyType":"","emotion":"","ctaType":"","riskLevel":"low","avoid":[]}`)
-}
-
-func BuildMissionInspireUserPrompt(in MissionInspireInput) string {
- var b strings.Builder
- if name := strings.TrimSpace(in.PersonaDisplayName); name != "" {
- b.WriteString("【人設名稱】")
- b.WriteString(name)
- b.WriteString("\n")
- }
- if p := strings.TrimSpace(in.PersonaBlock); p != "" {
- b.WriteString("【人設摘要(只用來校準語氣與受眾,不是題材庫)】\n")
- b.WriteString(p)
- b.WriteString("\n注意:不要把人設裡的名詞、興趣或裝飾符號直接變成話題。\n")
- }
- if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
- b.WriteString("【對標帳號】@")
- b.WriteString(strings.TrimPrefix(bench, "@"))
- b.WriteString("\n")
- }
- if aud := strings.TrimSpace(in.PersonaAudience); aud != "" {
- b.WriteString("【受眾方向】")
- b.WriteString(aud)
- b.WriteString("\n")
- }
- if goal := strings.TrimSpace(in.PersonaContentGoal); goal != "" {
- b.WriteString("【內容目標】")
- b.WriteString(goal)
- b.WriteString("\n")
- }
- if len(in.PersonaPillars) > 0 {
- b.WriteString("【內容支柱】")
- b.WriteString(strings.Join(in.PersonaPillars, "、"))
- b.WriteString("\n")
- }
- if keyword := strings.TrimSpace(in.UserKeyword); keyword != "" {
- b.WriteString("【使用者指定靈感關鍵字】")
- b.WriteString(keyword)
- b.WriteString("\n")
- }
- if direction := strings.TrimSpace(in.ContentDirection); direction != "" {
- b.WriteString("【使用者選擇內容方向】")
- b.WriteString(direction)
- b.WriteString("\n請讓 label、brief、angles 都符合這個方向;如果方向是 auto,請依熱度與人設自動選。\n")
- }
- if len(in.AvoidTopics) > 0 || len(in.RecentMissionLabels) > 0 || len(in.RecentSeedQueries) > 0 {
- b.WriteString("【近期已做過的任務(請避開)】\n")
- for _, topic := range in.AvoidTopics {
- if topic = strings.TrimSpace(topic); topic != "" {
- b.WriteString("- ")
- b.WriteString(topic)
- b.WriteString("\n")
- }
- }
- for _, label := range in.RecentMissionLabels {
- if label = strings.TrimSpace(label); label != "" {
- b.WriteString("- ")
- b.WriteString(label)
- b.WriteString("\n")
- }
- }
- for _, seed := range in.RecentSeedQueries {
- if seed = strings.TrimSpace(seed); seed != "" {
- b.WriteString("- 種子:")
- b.WriteString(seed)
- b.WriteString("\n")
- }
- }
- }
- if in.LLMOnly {
- b.WriteString("【模式】未取得 Threads/API/搜尋素材,請純依人設、使用者關鍵字與受眾推測靈感(勿假裝有外部熱搜)\n")
- } else if provider := strings.TrimSpace(in.WebSearchProvider); provider != "" {
- b.WriteString("【趨勢來源】")
- b.WriteString(provider)
- b.WriteString("\n")
- }
- b.WriteString("【近期趨勢訊號】\n")
- if len(in.TrendSnippets) == 0 {
- b.WriteString("(無外部趨勢結果,請改用人設推測近期 Threads 可能熱議方向)\n")
- } else {
- for i, item := range in.TrendSnippets {
- b.WriteString(fmt.Sprintf("[%d] 查詢:%s\n", i+1, strings.TrimSpace(item.Query)))
- if title := strings.TrimSpace(item.Title); title != "" {
- b.WriteString("標題:")
- b.WriteString(title)
- b.WriteString("\n")
- }
- if snippet := strings.TrimSpace(item.Snippet); snippet != "" {
- b.WriteString(snippet)
- b.WriteString("\n")
- }
- b.WriteString("\n")
- }
- }
- b.WriteString("請產出 Topic Radar 與 Content Plan JSON。")
- return b.String()
-}
-
-func InspireTrendSearchQueries(personaBrief, styleBenchmark string) []string {
- queries := []string{
- "Threads 台灣 熱門 關鍵字 討論 最近 一週",
- "Threads 台灣 請問 推薦 心得 最近",
- "社群 熱門 關鍵字 台灣 討論 Dcard PTT Threads",
- }
- context := strings.TrimSpace(personaBrief + " " + styleBenchmark)
- if context != "" {
- trimmed := context
- if len([]rune(trimmed)) > 24 {
- trimmed = string([]rune(trimmed)[:24])
- }
- queries = append(queries,
- trimmed+" Threads 熱門 關鍵字 討論",
- trimmed+" 請問 心得 推薦 Threads",
- )
- }
- return queries
-}
-
-func InspireThreadsSearchQueries(in MissionInspireThreadsInput) []string {
- seen := map[string]struct{}{}
- out := make([]string, 0, 6)
- add := func(q string) {
- q = cleanInspireQuery(q)
- if q == "" {
- return
- }
- if _, ok := seen[q]; ok {
- return
- }
- seen[q] = struct{}{}
- out = append(out, q)
- }
-
- keyword := cleanInspireQuery(in.Keyword)
- if keyword != "" {
- add(keyword)
- add(keyword + " 熱門")
- add(keyword + " 心得")
- add(keyword + " 請問")
- add(keyword + " 推薦")
- add(keyword + " 怎麼辦")
- }
- for _, pillar := range in.Pillars {
- if len(out) >= 6 {
- break
- }
- add(pillar)
- if keyword != "" {
- add(keyword + " " + pillar)
- }
- }
- for _, question := range in.Questions {
- if len(out) >= 6 {
- break
- }
- add(question)
- }
- if len(out) == 0 {
- context := strings.TrimSpace(in.PersonaBrief + " " + in.StyleBenchmark)
- if len([]rune(context)) > 24 {
- context = string([]rune(context)[:24])
- }
- add(context)
- }
- if len(out) > 6 {
- return out[:6]
- }
- return out
-}
-
-func CollectMissionInspireThreadsTrends(ctx context.Context, in MissionInspireThreadsInput) []MissionInspireTrendSnippet {
- if !in.Member.HasDiscoverPath() {
- return nil
- }
- if !in.Member.AllowsCrawler && !in.Member.AllowsThreadsAPI {
- return nil
- }
- queries := InspireThreadsSearchQueries(in)
- if len(queries) == 0 {
- return nil
- }
-
- type scoredSnippet struct {
- item MissionInspireTrendSnippet
- score int
- }
- seen := map[string]struct{}{}
- collected := make([]scoredSnippet, 0, 12)
- for _, query := range queries {
- posts, _, err := placement.Discover(ctx, placement.DiscoverRequest{
- Query: query,
- Keyword: query,
- Limit: 8,
- Member: in.Member,
- Crawler: in.Crawler,
- })
- if err != nil {
- continue
- }
- for _, post := range posts {
- text := strings.TrimSpace(post.Text)
- if len([]rune(text)) < 18 {
- continue
- }
- key := strings.TrimSpace(post.Permalink)
- if key == "" {
- key = strings.TrimSpace(post.ExternalID)
- }
- if key == "" {
- key = text
- }
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- title := strings.TrimSpace(post.Author)
- if title != "" {
- title = "Threads @" + strings.TrimPrefix(title, "@")
- } else {
- title = "Threads 熱門貼文"
- }
- collected = append(collected, scoredSnippet{
- item: MissionInspireTrendSnippet{
- Query: query,
- Title: title,
- Snippet: shortenRunes(text, 180),
- URL: strings.TrimSpace(post.Permalink),
- },
- score: post.LikeCount + post.ReplyCount*2,
- })
- }
- if len(collected) >= 12 {
- break
- }
- }
- sort.SliceStable(collected, func(i, j int) bool {
- return collected[i].score > collected[j].score
- })
- if len(collected) > 8 {
- collected = collected[:8]
- }
- out := make([]MissionInspireTrendSnippet, 0, len(collected))
- for _, item := range collected {
- out = append(out, item.item)
- }
- return out
-}
-
-func cleanInspireQuery(raw string) string {
- raw = strings.TrimSpace(raw)
- raw = strings.ReplaceAll(raw, "\n", " ")
- raw = strings.Join(strings.Fields(raw), " ")
- if len([]rune(raw)) > 24 {
- raw = string([]rune(raw)[:24])
- }
- return raw
-}
-
-func shortenRunes(raw string, max int) string {
- raw = strings.TrimSpace(raw)
- if max <= 0 || len([]rune(raw)) <= max {
- return raw
- }
- return string([]rune(raw)[:max]) + "…"
-}
-
-func ParseMissionInspireOutput(raw string) (MissionInspireOutput, error) {
- payload, err := extractCopyMapJSON(raw)
- if err != nil {
- return MissionInspireOutput{}, err
- }
- var root map[string]json.RawMessage
- if err := json.Unmarshal(payload, &root); err != nil {
- return MissionInspireOutput{}, err
- }
- out := MissionInspireOutput{
- Label: firstJSONString(root, "label", "title", "mission_label"),
- SeedQuery: firstJSONString(root, "seedQuery", "seed_query", "seed", "keywords"),
- Brief: firstJSONString(root, "brief", "description", "goal"),
- TrendReason: firstJSONString(root, "trendReason", "trend_reason", "reason"),
- Mission: firstJSONString(root, "mission", "contentMission", "content_mission"),
- TargetAudience: firstJSONString(root, "targetAudience", "target_audience", "audience"),
- OpeningType: firstJSONString(root, "openingType", "opening_type", "hookType", "hook_type"),
- BodyType: firstJSONString(root, "bodyType", "body_type", "structure", "bodyStructure"),
- Emotion: firstJSONString(root, "emotion", "emotionalLever", "emotional_lever"),
- CtaType: firstJSONString(root, "ctaType", "cta_type", "cta"),
- RiskLevel: firstJSONString(root, "riskLevel", "risk_level", "risk"),
- }
- if out.Label == "" || out.SeedQuery == "" || out.Brief == "" {
- return MissionInspireOutput{}, fmt.Errorf("missing label, seedQuery, or brief")
- }
- if rawKW, ok := root["trendKeywords"]; ok {
- out.TrendKeywords = parseFlexibleStringList(rawKW)
- }
- if len(out.TrendKeywords) == 0 {
- if rawKW, ok := root["trend_keywords"]; ok {
- out.TrendKeywords = parseFlexibleStringList(rawKW)
- }
- }
- if rawAngles, ok := root["angles"]; ok {
- out.Angles = parseFlexibleStringList(rawAngles)
- }
- if len(out.Angles) == 0 {
- if rawAngles, ok := root["contentAngles"]; ok {
- out.Angles = parseFlexibleStringList(rawAngles)
- }
- }
- if rawAvoid, ok := root["avoid"]; ok {
- out.Avoid = parseFlexibleStringList(rawAvoid)
- }
- if len(out.Avoid) == 0 {
- if rawAvoid, ok := root["avoidTopics"]; ok {
- out.Avoid = parseFlexibleStringList(rawAvoid)
- }
- }
- return out, nil
-}
diff --git a/old/backend/internal/library/viral/mission_inspire_collect.go b/old/backend/internal/library/viral/mission_inspire_collect.go
deleted file mode 100644
index febd496..0000000
--- a/old/backend/internal/library/viral/mission_inspire_collect.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package viral
-
-import (
- "context"
- "strings"
- "time"
-
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/websearch"
-)
-
-const maxInspireTrendSnippets = 14
-
-func CollectMissionInspireTrends(
- ctx context.Context,
- client websearch.Client,
- member placement.MemberContext,
- personaBrief, styleBenchmark string,
-) []MissionInspireTrendSnippet {
- if client == nil || !client.Enabled() {
- return nil
- }
- queries := InspireTrendSearchQueries(personaBrief, styleBenchmark)
- out := make([]MissionInspireTrendSnippet, 0, maxInspireTrendSnippets)
- seen := map[string]struct{}{}
-
- for _, query := range queries {
- if len(out) >= maxInspireTrendSnippets {
- break
- }
- res, err := client.Search(ctx, websearch.SearchOptions{
- Query: query,
- Limit: 5,
- Mode: websearch.ModeKnowledgeExpand,
- Country: member.BraveCountry,
- SearchLang: member.BraveSearchLang,
- UserLocation: member.ExaUserLocation,
- StartPublishedDate: placement.FormatPublishedAfterISO(7, time.Now().UTC()),
- })
- if err != nil || res.Status != "success" || len(res.Results) == 0 {
- continue
- }
- for _, item := range res.Results {
- if len(out) >= maxInspireTrendSnippets {
- break
- }
- title := strings.TrimSpace(item.Title)
- snippet := strings.TrimSpace(item.Snippet)
- if title == "" && snippet == "" {
- continue
- }
- if looksLikeNewsResult(title, snippet, item.URL) {
- continue
- }
- key := strings.ToLower(title + "|" + snippet)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, MissionInspireTrendSnippet{
- Query: query,
- Title: title,
- Snippet: snippet,
- URL: strings.TrimSpace(item.URL),
- })
- }
- }
- return out
-}
-
-func looksLikeNewsResult(title, snippet, url string) bool {
- text := strings.ToLower(title + " " + snippet + " " + url)
- newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
- socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
- newsScore := 0
- for _, signal := range newsSignals {
- if strings.Contains(text, strings.ToLower(signal)) {
- newsScore++
- }
- }
- if newsScore == 0 {
- return false
- }
- for _, signal := range socialSignals {
- if strings.Contains(text, strings.ToLower(signal)) {
- return false
- }
- }
- return true
-}
diff --git a/old/backend/internal/library/viral/mission_inspire_test.go b/old/backend/internal/library/viral/mission_inspire_test.go
deleted file mode 100644
index 990e91f..0000000
--- a/old/backend/internal/library/viral/mission_inspire_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package viral
-
-import (
- "strings"
- "testing"
-)
-
-func TestParseMissionInspireOutput(t *testing.T) {
- raw := `{"label":"轉職焦慮語錄","seedQuery":"轉職 被裁 焦慮","brief":"想找最近 Threads 上互動高的轉職心情貼文。","trendReason":"Google 熱搜近期職場話題升溫","trendKeywords":["轉職","裁員","面試"]}`
- out, err := ParseMissionInspireOutput(raw)
- if err != nil {
- t.Fatalf("parse: %v", err)
- }
- if out.Label != "轉職焦慮語錄" || out.SeedQuery == "" || out.Brief == "" {
- t.Fatalf("unexpected output: %+v", out)
- }
- if len(out.TrendKeywords) != 3 {
- t.Fatalf("expected 3 trend keywords, got %d", len(out.TrendKeywords))
- }
-}
-
-func TestBuildMissionInspireUserPromptLLMOnly(t *testing.T) {
- prompt := BuildMissionInspireUserPrompt(MissionInspireInput{
- PersonaDisplayName: "測試人設",
- PersonaBrief: "職場焦慮",
- LLMOnly: true,
- })
- if !strings.Contains(prompt, "未取得 Threads/API/搜尋素材") {
- t.Fatalf("expected llm-only hint in prompt: %s", prompt)
- }
- if !strings.Contains(prompt, "無外部趨勢結果") {
- t.Fatalf("expected empty trend fallback in prompt: %s", prompt)
- }
-}
diff --git a/old/backend/internal/library/viral/mission_research_map.go b/old/backend/internal/library/viral/mission_research_map.go
deleted file mode 100644
index d03ebb2..0000000
--- a/old/backend/internal/library/viral/mission_research_map.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package viral
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/llmjson"
-)
-
-// MissionResearchMap is the structured copy-mission research map (single LLM call).
-type MissionResearchMap struct {
- AudienceSummary string `json:"audienceSummary"`
- ContentGoal string `json:"contentGoal"`
- Questions []string `json:"questions"`
- Pillars []string `json:"pillars"`
- Exclusions []string `json:"exclusions"`
- SuggestedTags []SuggestedTag `json:"suggestedTags"`
- BenchmarkNotes string `json:"benchmarkNotes"`
-}
-
-func BuildMissionResearchMapSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 拷貝任務的「延伸知識與相似帳號研究顧問」。目標:把使用者的完整意圖延伸成可產文的知識地圖,並提供適合人工參考的相似帳號搜尋方向;不要把題目壓扁成泛泛短詞。
-
-規則:
-1. audienceSummary:必填,2~4 句描述「受眾是誰」(年齡/情境/痛點/會在 Threads 搜什麼),不要只寫人設本人
-2. 必須保留使用者原始題目的核心主詞、情境與目的。例如「備孕男生要吃什麼保健品」不可簡化成「老公吃什麼」
-3. contentGoal:用延伸知識與人設產出可發的 Threads 內容;爆款貼文只作為可選參考,不是必經來源
-4. pillars:延伸知識支柱,至少 4 個;**必須是字串陣列**,每項要能支撐一篇內容,例如營養素、檢查、生活習慣、常見迷思
-5. questions:受眾會搜尋或想問的完整問題,5+ 個;字串陣列,需保留主詞與情境
-6. exclusions:不要寫的內容,至少 4 個;包含過度醫療承諾、偏方、恐嚇、與題目無關的家庭日常
-7. suggestedTags:6~8 個「搜尋查詢」,不是短 hashtag
- - 每個含 tag, reason, searchIntent(痛點|知識|經驗|對比|工具|帳號), searchType(查詢|情境|帳號)
- - tag 6~22 字,必須保留核心意圖;可以像真人搜尋句,例如「備孕男生保健品」「精蟲品質吃什麼」「男性備孕葉酸鋅」
- - 不要輸出「老公吃什麼」「多閱讀」這種失去主題的泛詞
-8. benchmarkNotes:整理延伸知識重點與人工看相似帳號時的觀察方向;可列 3~5 個重點
-9. 繁體中文;只回傳 JSON(含 audienceSummary)`)
-}
-
-func BuildMissionResearchMapUserPrompt(in CopyResearchMapInput) string {
- var b strings.Builder
- b.WriteString("【任務名稱】")
- b.WriteString(strings.TrimSpace(in.Label))
- b.WriteString("\n【種子關鍵字/近期熱詞】")
- b.WriteString(strings.TrimSpace(in.SeedQuery))
- b.WriteString("\n【這次想找什麼】\n")
- b.WriteString(strings.TrimSpace(in.Brief))
- if p := strings.TrimSpace(in.Persona); p != "" {
- b.WriteString("\n【我的人設(仿寫時會套用,地圖請對準受眾與話題,不要只寫我自己)】\n")
- b.WriteString(p)
- }
- if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
- b.WriteString("\n【長期對標帳號參考】@")
- b.WriteString(strings.TrimPrefix(bench, "@"))
- b.WriteString("\n")
- }
- if aud := strings.TrimSpace(in.PersonaAudienceSummary); aud != "" {
- b.WriteString("\n【人設層級受眾研究(請延伸為本次任務受眾,勿只複製)】\n")
- b.WriteString(aud)
- if goal := strings.TrimSpace(in.PersonaContentGoal); goal != "" {
- b.WriteString("\n內容目標參考:")
- b.WriteString(goal)
- }
- if len(in.PersonaQuestions) > 0 {
- b.WriteString("\n受眾提問參考:")
- b.WriteString(strings.Join(in.PersonaQuestions, "、"))
- }
- if len(in.PersonaPillars) > 0 {
- b.WriteString("\n內容支柱參考:")
- b.WriteString(strings.Join(in.PersonaPillars, "、"))
- }
- b.WriteString("\n")
- }
- b.WriteString("\n請產出拷貝任務研究地圖 JSON。請把 suggestedTags 當成完整搜尋查詢,不要壓成短 hashtag。")
- return b.String()
-}
-
-func MissionResearchMapJSONOnlyRetryPrompt() string {
- return strings.TrimSpace(`上次回覆沒有包含可解析的 JSON。請**只**輸出一個 JSON 物件:
-- 直接以 { 開頭、以 } 結尾,不要任何思考過程、說明文字或 markdown 圍欄
-- 需含 audienceSummary、contentGoal、questions、pillars、exclusions、suggestedTags、benchmarkNotes 欄位
-- suggestedTags 至少 4 項,每項含 tag/reason/searchIntent/searchType
-- 不要使用 ... 省略,也不要加註解或尾端逗號`)
-}
-
-func ParseMissionResearchMapOutput(raw string) (MissionResearchMap, error) {
- payload, err := extractCopyMapJSON(raw)
- if err != nil {
- return MissionResearchMap{}, err
- }
- var root map[string]json.RawMessage
- if err := llmjson.Unmarshal(payload, &root); err != nil {
- return MissionResearchMap{}, fmt.Errorf("parse mission research map: %w", err)
- }
- out := MissionResearchMap{
- AudienceSummary: firstJSONString(root, "audienceSummary", "audience_summary"),
- ContentGoal: firstJSONString(root, "contentGoal", "content_goal"),
- BenchmarkNotes: firstJSONString(root, "benchmarkNotes", "benchmark_notes"),
- Questions: parseFlexibleStringList(pickRawMessage(root, "questions")),
- Pillars: parseFlexibleStringList(pickRawMessage(root, "pillars")),
- Exclusions: parseFlexibleStringList(pickRawMessage(root, "exclusions")),
- SuggestedTags: parseFlexibleSuggestedTags(pickRawMessage(root, "suggestedTags", "suggested_tags")),
- }
- if strings.TrimSpace(out.AudienceSummary) == "" {
- return MissionResearchMap{}, fmt.Errorf("mission research map missing audienceSummary")
- }
- if len(out.SuggestedTags) < 4 {
- return MissionResearchMap{}, fmt.Errorf("mission research map needs at least 4 suggested tags")
- }
- return out, nil
-}
-
-func cleanSuggestedTags(tags []SuggestedTag) []SuggestedTag {
- out := []SuggestedTag{}
- seen := map[string]struct{}{}
- for _, item := range tags {
- tag := strings.TrimSpace(item.Tag)
- if tag == "" {
- continue
- }
- if _, ok := seen[tag]; ok {
- continue
- }
- seen[tag] = struct{}{}
- out = append(out, SuggestedTag{
- Tag: tag,
- Reason: strings.TrimSpace(item.Reason),
- SearchIntent: strings.TrimSpace(item.SearchIntent),
- SearchType: strings.TrimSpace(item.SearchType),
- })
- }
- return out
-}
-
-func ToEntityMissionResearchMap(m MissionResearchMap) map[string]any {
- tags := make([]map[string]any, 0, len(m.SuggestedTags))
- for _, item := range m.SuggestedTags {
- tags = append(tags, map[string]any{
- "tag": item.Tag,
- "reason": item.Reason,
- "search_intent": item.SearchIntent,
- "search_type": item.SearchType,
- })
- }
- return map[string]any{
- "audience_summary": m.AudienceSummary,
- "content_goal": m.ContentGoal,
- "questions": m.Questions,
- "pillars": m.Pillars,
- "exclusions": m.Exclusions,
- "suggested_tags": tags,
- "benchmark_notes": m.BenchmarkNotes,
- }
-}
diff --git a/old/backend/internal/library/viral/mission_research_map_test.go b/old/backend/internal/library/viral/mission_research_map_test.go
deleted file mode 100644
index 6b58593..0000000
--- a/old/backend/internal/library/viral/mission_research_map_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package viral
-
-import (
- "strings"
- "testing"
-)
-
-func TestParseMissionResearchMapOutput_ObjectPillars(t *testing.T) {
- raw := `{
- "audienceSummary": "轉職焦慮的上班族",
- "contentGoal": "找近期高互動轉職心情貼文",
- "questions": ["轉職會後悔嗎", "被裁怎麼辦"],
- "pillars": [
- {"title": "語錄型", "description": "一句話戳中焦慮"},
- {"title": "故事型", "description": "親身轉職經驗"}
- ],
- "exclusions": ["純業配", "無結構閒聊"],
- "suggestedTags": [
- {"tag": "轉職焦慮", "reason": "近期熱詞", "searchIntent": "痛點", "searchType": "短詞"},
- {"tag": "被裁", "reason": "高互動", "searchIntent": "痛點", "searchType": "短詞"},
- {"tag": "面試失敗", "reason": "共鳴", "searchIntent": "經驗", "searchType": "情境"},
- {"tag": "裸辭", "reason": "討論多", "searchIntent": "語錄", "searchType": "短詞"}
- ],
- "benchmarkNotes": "互動高且 hook 清楚"
-}`
- out, err := ParseMissionResearchMapOutput(raw)
- if err != nil {
- t.Fatalf("ParseMissionResearchMapOutput: %v", err)
- }
- if len(out.Pillars) < 2 {
- t.Fatalf("expected parsed pillars, got %#v", out.Pillars)
- }
- if out.Pillars[0] == "" {
- t.Fatal("first pillar should not be empty")
- }
- if len(out.SuggestedTags) < 4 {
- t.Fatalf("expected suggested tags, got %#v", out.SuggestedTags)
- }
-}
-
-func TestParseMissionResearchMapOutput_StringSuggestedTags(t *testing.T) {
- raw := `{
- "audienceSummary": "新手媽媽",
- "contentGoal": "找育兒爆款",
- "questions": ["哄睡", "副食品"],
- "pillars": ["清單型", "故事型", "語錄型", "問答型"],
- "exclusions": ["業配", "晒娃", "無重點", "抄襲"],
- "suggestedTags": ["哄睡", "副食品", "崩潰", "育兒"],
- "benchmarkNotes": "留言多"
-}`
- out, err := ParseMissionResearchMapOutput(raw)
- if err != nil {
- t.Fatalf("ParseMissionResearchMapOutput: %v", err)
- }
- if len(out.SuggestedTags) != 4 {
- t.Fatalf("expected 4 tags, got %#v", out.SuggestedTags)
- }
-}
-
-func TestBuildMissionResearchMapSystemPromptPreservesIntent(t *testing.T) {
- prompt := BuildMissionResearchMapSystemPrompt()
- if !strings.Contains(prompt, "不可簡化成「老公吃什麼」") {
- t.Fatalf("expected prompt to warn against intent-flattening, got %s", prompt)
- }
- if !strings.Contains(prompt, "搜尋查詢") {
- t.Fatalf("expected suggested tags to be framed as search queries")
- }
-}
diff --git a/old/backend/internal/library/viral/mission_topic.go b/old/backend/internal/library/viral/mission_topic.go
deleted file mode 100644
index 7db6f6d..0000000
--- a/old/backend/internal/library/viral/mission_topic.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package viral
-
-import (
- "strings"
- "unicode"
-
- "haixun-backend/internal/library/placement"
-)
-
-type topicMatchTerms struct {
- Anchors []string
- Terms []string
-}
-
-func missionTopicMatchTerms(seed, label string, hints []string) topicMatchTerms {
- seenAnchors := map[string]struct{}{}
- seenTerms := map[string]struct{}{}
- out := topicMatchTerms{}
- addTerm := func(term string, anchor bool) {
- term = normaliseTopicToken(term)
- if term == "" || isGenericTopicToken(term) {
- return
- }
- if _, ok := seenTerms[term]; !ok {
- seenTerms[term] = struct{}{}
- out.Terms = append(out.Terms, term)
- }
- if anchor {
- if _, ok := seenAnchors[term]; !ok {
- seenAnchors[term] = struct{}{}
- out.Anchors = append(out.Anchors, term)
- }
- }
- }
- addSource := func(raw string, anchor bool) {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return
- }
- compact := compactTopicPhrase(raw)
- addTerm(compact, anchor)
- for _, token := range splitTopicTokens(raw) {
- addTerm(token, anchor)
- }
- }
-
- addSource(seed, true)
- addSource(label, true)
- anchorHints := len(out.Anchors) == 0
- for _, hint := range hints {
- addSource(hint, anchorHints)
- }
- return out
-}
-
-// missionPostMatchesTopic is intentionally stricter than topicTopicHits:
-// mission scan candidates must match the post body, not only the query/tag
-// that produced the crawl result. This prevents crawler/search drift from
-// admitting high-engagement but unrelated posts.
-func missionPostMatchesTopic(post placement.ScanCandidate, terms topicMatchTerms) bool {
- text := strings.ToLower(strings.TrimSpace(post.Text))
- if len(terms.Anchors) == 0 && len(terms.Terms) == 0 {
- return text != ""
- }
- if text == "" {
- return false
- }
- for _, term := range terms.Anchors {
- if term != "" && strings.Contains(text, term) {
- return true
- }
- }
- hits := 0
- for _, term := range terms.Terms {
- if term != "" && strings.Contains(text, term) {
- hits++
- if hits >= 2 {
- return true
- }
- }
- }
- return false
-}
-
-func splitTopicTokens(raw string) []string {
- return strings.FieldsFunc(raw, func(r rune) bool {
- if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) {
- return true
- }
- switch r {
- case ',', '。', '、', ':', ';', '!', '?', '「', '」', '『', '』', '(', ')', '【', '】':
- return true
- default:
- return false
- }
- })
-}
-
-func compactTopicPhrase(raw string) string {
- var b strings.Builder
- for _, r := range raw {
- if unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) {
- continue
- }
- b.WriteRune(r)
- }
- return b.String()
-}
-
-func normaliseTopicToken(raw string) string {
- token := strings.ToLower(strings.TrimSpace(raw))
- if token == "" {
- return ""
- }
- token = strings.Trim(token, "##,,.。::;;!!??()()[]【】\"'「」『』")
- if len([]rune(token)) < 2 {
- return ""
- }
- return token
-}
-
-func isGenericTopicToken(token string) bool {
- switch token {
- case "分享", "心得", "推薦", "請問", "求助", "問題", "方法", "技巧", "經驗",
- "熱門", "話題", "最近", "大家", "有人", "可以", "如何", "怎麼", "為什麼",
- "品質", "閱讀", "更多", "多閱讀", "老公", "老婆", "男友", "女友":
- return true
- default:
- return false
- }
-}
diff --git a/old/backend/internal/library/viral/quality.go b/old/backend/internal/library/viral/quality.go
deleted file mode 100644
index fdc8902..0000000
--- a/old/backend/internal/library/viral/quality.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package viral
-
-import "haixun-backend/internal/library/placement"
-
-// Mission-quality gates for copy-mission viral patrol (stricter than generic patrol).
-const (
- MissionQualityMinLikes = 18
- MissionQualityMinEngagement = 50
- MissionVerifiedMinLikes = 10
- MissionVerifiedMinEngagement = 35
- MissionInfluencerMinFollowers = 5000
-)
-
-// PassesMissionQualityCandidate filters copy-mission scan results toward higher-signal posts.
-// AuthorVerified and FollowerCount are optional enrichments (often missing on Threads API);
-// when absent the default engagement bar still applies — callers should fall back to
-// PassesViralCandidate rather than treating empty optional signals as failure.
-func PassesMissionQualityCandidate(text string, likes, replies, engagement int, verified bool, followerCount int, exclusions []string) bool {
- if !PassesViralCandidate(text, likes, replies, engagement, exclusions) {
- return false
- }
- if verified {
- return likes >= MissionVerifiedMinLikes && engagement >= MissionVerifiedMinEngagement
- }
- if followerCount >= MissionInfluencerMinFollowers {
- return likes >= 12 && engagement >= 40
- }
- return likes >= MissionQualityMinLikes && engagement >= MissionQualityMinEngagement
-}
-
-func MergeAuthorSignals(prev, next placement.ScanCandidate) placement.ScanCandidate {
- if next.AuthorVerified {
- prev.AuthorVerified = true
- }
- if next.FollowerCount > prev.FollowerCount {
- prev.FollowerCount = next.FollowerCount
- }
- return prev
-}
diff --git a/old/backend/internal/library/viral/quality_test.go b/old/backend/internal/library/viral/quality_test.go
deleted file mode 100644
index 353a188..0000000
--- a/old/backend/internal/library/viral/quality_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package viral
-
-import "testing"
-
-func TestPassesMissionQualityCandidate_verifiedLowerBar(t *testing.T) {
- if !PassesMissionQualityCandidate("轉職面試技巧分享心得", 12, 2, 40, true, 0, nil) {
- t.Fatal("verified author should pass with moderate engagement")
- }
-}
-
-func TestPassesMissionQualityCandidate_unverifiedStricter(t *testing.T) {
- if PassesMissionQualityCandidate("轉職面試技巧分享心得", 12, 2, 40, false, 0, nil) {
- t.Fatal("unverified author should not pass with low engagement")
- }
- if !PassesMissionQualityCandidate("轉職面試技巧分享心得", 25, 4, 65, false, 0, nil) {
- t.Fatal("unverified author should pass with strong engagement")
- }
-}
diff --git a/old/backend/internal/library/viral/reference_accounts.go b/old/backend/internal/library/viral/reference_accounts.go
deleted file mode 100644
index eff91f4..0000000
--- a/old/backend/internal/library/viral/reference_accounts.go
+++ /dev/null
@@ -1,306 +0,0 @@
-package viral
-
-import (
- "fmt"
- "math"
- "sort"
- "strings"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/library/placement"
- libthreads "haixun-backend/internal/library/threadsapi"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
-)
-
-const (
- RefAccountMinBestEngagement = 50
- RefAccountMinBestLikes = 18
- RefAccountMinTotalEngagement = 80
- RefVerifiedMinBestEngagement = 35
- RefVerifiedMinBestLikes = 10
-)
-
-// ReferenceRankWeights controls the weighted sort key applied to ranked
-// reference authors. Defaults preserve historical precedence (verified first,
-// then follower count, then total engagement, then best single-post
-// engagement) and introduce topic relevance as a tie-breaker multiplier.
-type ReferenceRankWeights struct {
- VerifiedW int
- FollowerW int
- TotalEngagementW int
- BestEngagementW int
- TopicRelevanceW int
-}
-
-// DefaultReferenceRankWeights returns the canonical weights used by
-// BuildReferenceAccountsFromScan. Callers may pass a customised copy via
-// ReferenceAccountInput in a future iteration; Phase 1 keeps it internal.
-func DefaultReferenceRankWeights() ReferenceRankWeights {
- return ReferenceRankWeights{
- VerifiedW: 4,
- FollowerW: 2,
- TotalEngagementW: 1,
- BestEngagementW: 1,
- TopicRelevanceW: 2,
- }
-}
-
-// rankScore converts aggregated author signals into a weighted integer sort
-// key. Follower count is log-scaled so 10M-follower mega accounts do not
-// dominate niche candidates with high topic relevance.
-func (w ReferenceRankWeights) rankScore(item referenceAuthorAgg) int {
- score := 0
- if item.verified {
- score += w.VerifiedW * 1000
- }
- followerBucket := 0
- switch {
- case item.followerCount >= 1_000_000:
- followerBucket = 4
- case item.followerCount >= 100_000:
- followerBucket = 3
- case item.followerCount >= 10_000:
- followerBucket = 2
- case item.followerCount >= 1_000:
- followerBucket = 1
- }
- score += w.FollowerW * followerBucket * 100
- score += w.TotalEngagementW * item.totalEngagement
- score += w.BestEngagementW * item.bestEngagement
- score += w.TopicRelevanceW * item.topicHits * 50
- return score
-}
-
-type ReferenceAccountInput struct {
- SeedQuery string
- Label string
- Posts []placement.ScanCandidate
- Limit int
- ExcludedUsernames []string
-}
-
-type referenceAuthorAgg struct {
- username string
- verified bool
- followerCount int
- totalEngagement int
- bestEngagement int
- bestLikes int
- bestReplies int
- postCount int
- topicHits int
- sampleText string
- sampleSearchTag string
- samplePermalink string
-}
-
-// BuildReferenceAccountsFromScan lists authors from patrol posts that match the
-// mission topic and pass quality gates. Verified/follower are optional bonuses;
-// if strict gates yield nothing, falls back to baseline viral engagement.
-func BuildReferenceAccountsFromScan(in ReferenceAccountInput) []missionentity.SimilarAccount {
- out := buildReferenceAccountsFromScan(in, true)
- if len(out) > 0 {
- return out
- }
- return buildReferenceAccountsFromScan(in, false)
-}
-
-func buildReferenceAccountsFromScan(in ReferenceAccountInput, strictQuality bool) []missionentity.SimilarAccount {
- limit := in.Limit
- if limit <= 0 {
- limit = MaxSimilarAccounts
- }
- byUser := map[string]referenceAuthorAgg{}
- terms := normalisedTopicTerms(in.SeedQuery, in.Label)
- weights := DefaultReferenceRankWeights()
- now := clock.NowUnixNano()
- topicDenom := math.Max(1, float64(len(terms)))
- for _, post := range in.Posts {
- user := strings.TrimSpace(post.Author)
- if user == "" || !isValidUsername(user) {
- continue
- }
- if isExcluded(in.ExcludedUsernames, user) {
- continue
- }
- hits := topicTopicHits(post, terms)
- if hits == 0 {
- continue
- }
- if strictQuality {
- if !PassesMissionQualityCandidate(
- post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore,
- post.AuthorVerified, post.FollowerCount, nil,
- ) {
- continue
- }
- } else if !PassesViralCandidate(
- post.Text, post.LikeCount, post.ReplyCount, post.EngagementScore, nil,
- ) {
- continue
- }
- key := strings.ToLower(user)
- prev := byUser[key]
- prev.username = user
- if post.AuthorVerified {
- prev.verified = true
- }
- if post.FollowerCount > prev.followerCount {
- prev.followerCount = post.FollowerCount
- }
- prev.postCount++
- prev.totalEngagement += post.EngagementScore
- if hits > prev.topicHits {
- prev.topicHits = hits
- }
- if post.EngagementScore > prev.bestEngagement {
- prev.bestEngagement = post.EngagementScore
- prev.bestLikes = post.LikeCount
- prev.bestReplies = post.ReplyCount
- text := strings.TrimSpace(post.Text)
- if len([]rune(text)) > 80 {
- text = string([]rune(text)[:80])
- }
- prev.sampleText = text
- prev.sampleSearchTag = strings.TrimSpace(post.SearchTag)
- prev.samplePermalink = strings.TrimSpace(post.Permalink)
- }
- byUser[key] = prev
- }
-
- ranked := make([]referenceAuthorAgg, 0, len(byUser))
- for _, item := range byUser {
- if qualifiesReferenceAuthor(item, strictQuality) {
- ranked = append(ranked, item)
- }
- }
- sort.Slice(ranked, func(i, j int) bool {
- si := weights.rankScore(ranked[i])
- sj := weights.rankScore(ranked[j])
- if si != sj {
- return si > sj
- }
- // stable historical tie-breakers (verified first, follower,
- // total engagement, best engagement) preserved for determinism.
- if ranked[i].verified != ranked[j].verified {
- return ranked[i].verified
- }
- if ranked[i].followerCount != ranked[j].followerCount {
- return ranked[i].followerCount > ranked[j].followerCount
- }
- if ranked[i].totalEngagement != ranked[j].totalEngagement {
- return ranked[i].totalEngagement > ranked[j].totalEngagement
- }
- return ranked[i].bestEngagement > ranked[j].bestEngagement
- })
- if len(ranked) > limit {
- ranked = ranked[:limit]
- }
-
- out := make([]missionentity.SimilarAccount, 0, len(ranked))
- for _, item := range ranked {
- conf := "medium"
- if item.verified {
- conf = "high"
- } else if item.bestEngagement >= HotEngagementScore || item.totalEngagement >= 120 {
- conf = "high"
- }
- out = append(out, missionentity.SimilarAccount{
- Username: item.username,
- Reason: formatReferenceReason(item),
- Source: "scan",
- MatchedSource: []string{"scan"},
- Confidence: conf,
- Status: missionentity.SimilarAccountStatusRecommended,
- TopicRelevance: float64(item.topicHits) / topicDenom,
- LastSeenAt: now,
- ProfileURL: libthreads.ProfileURLFromPermalink(item.samplePermalink, item.username),
- AuthorVerified: item.verified,
- FollowerCount: item.followerCount,
- EngagementScore: item.bestEngagement,
- LikeCount: item.bestLikes,
- ReplyCount: item.bestReplies,
- PostCount: item.postCount,
- })
- }
- return out
-}
-
-func qualifiesReferenceAuthor(item referenceAuthorAgg, strictQuality bool) bool {
- if item.postCount == 0 {
- return false
- }
- if item.verified {
- return item.bestLikes >= RefVerifiedMinBestLikes &&
- (item.bestEngagement >= RefVerifiedMinBestEngagement || item.totalEngagement >= 60)
- }
- if strictQuality {
- if item.bestLikes < RefAccountMinBestLikes {
- return false
- }
- return item.bestEngagement >= RefAccountMinBestEngagement || item.totalEngagement >= RefAccountMinTotalEngagement
- }
- return item.bestLikes >= 8 && item.bestEngagement >= MinEngagementScore
-}
-
-func postTopicRelevant(post placement.ScanCandidate, seed, label string) bool {
- return topicTopicHits(post, normalisedTopicTerms(seed, label)) > 0
-}
-
-// topicTopicHits counts how many normalised topic terms appear (case-folded
-// substring match against the post's text and search tag). Returning a hit
-// count (instead of a boolean) lets the ranking weight reward posts that are
-// relevant across multiple seed/label tokens — a coarse but dependency-free
-// CJK-friendly proxy for topic similarity.
-func topicTopicHits(post placement.ScanCandidate, terms []string) int {
- if len(terms) == 0 {
- text := strings.TrimSpace(post.Text)
- tag := strings.TrimSpace(post.SearchTag)
- if text == "" && tag == "" {
- return 0
- }
- return 1
- }
- text := strings.ToLower(strings.TrimSpace(post.Text))
- tag := strings.ToLower(strings.TrimSpace(post.SearchTag))
- hits := 0
- for _, term := range terms {
- if term == "" {
- continue
- }
- if strings.Contains(text, term) || strings.Contains(tag, term) {
- hits++
- }
- }
- return hits
-}
-
-// normalisedTopicTerms lowercases and de-spaces the seed-query and label while
-// also exposing coarse tokens split on whitespace and punctuation. It remains
-// dependency-free and CJK-friendly, but avoids matching only a long exact phrase.
-func normalisedTopicTerms(seed, label string) []string {
- out := []string{}
- terms := missionTopicMatchTerms(seed, label, nil)
- seen := map[string]struct{}{}
- for _, term := range append(append([]string{}, terms.Anchors...), terms.Terms...) {
- if term == "" {
- continue
- }
- if _, ok := seen[term]; ok {
- continue
- }
- seen[term] = struct{}{}
- out = append(out, term)
- }
- return out
-}
-
-func formatReferenceReason(item referenceAuthorAgg) string {
- if item.sampleText != "" {
- return item.sampleText
- }
- if item.sampleSearchTag != "" {
- return fmt.Sprintf("標籤「%s」高互動作者", item.sampleSearchTag)
- }
- return "本次海巡高互動作者"
-}
diff --git a/old/backend/internal/library/viral/reference_accounts_fallback_test.go b/old/backend/internal/library/viral/reference_accounts_fallback_test.go
deleted file mode 100644
index 194dfc2..0000000
--- a/old/backend/internal/library/viral/reference_accounts_fallback_test.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package viral
-
-import (
- "testing"
-
- "haixun-backend/internal/library/placement"
-)
-
-func TestBuildReferenceAccountsFromScan_relaxedFallback(t *testing.T) {
- got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
- SeedQuery: "轉職",
- Label: "轉職",
- Posts: []placement.ScanCandidate{
- {Author: "warm_user", Text: "轉職心得分享", SearchTag: "轉職", LikeCount: 10, ReplyCount: 2, EngagementScore: 32},
- },
- Limit: 5,
- })
- if len(got) != 1 {
- t.Fatalf("expected relaxed fallback account, got %d", len(got))
- }
- if got[0].AuthorVerified {
- t.Fatal("verified must stay false when not provided")
- }
-}
diff --git a/old/backend/internal/library/viral/reference_accounts_test.go b/old/backend/internal/library/viral/reference_accounts_test.go
deleted file mode 100644
index 9c15abc..0000000
--- a/old/backend/internal/library/viral/reference_accounts_test.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package viral
-
-import (
- "testing"
-
- "haixun-backend/internal/library/placement"
-)
-
-func TestBuildReferenceAccountsFromScan_filtersLowEngagement(t *testing.T) {
- got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
- SeedQuery: "轉職",
- Label: "轉職語錄",
- Posts: []placement.ScanCandidate{
- {Author: "weak_user", Text: "轉職心得", SearchTag: "轉職", LikeCount: 2, ReplyCount: 0, EngagementScore: 10},
- {Author: "hot_user", Text: "轉職面試技巧分享", SearchTag: "轉職", LikeCount: 30, ReplyCount: 5, EngagementScore: 85},
- },
- Limit: 5,
- })
- if len(got) != 1 {
- t.Fatalf("expected 1 account, got %d", len(got))
- }
- if got[0].Username != "hot_user" {
- t.Fatalf("unexpected username %q", got[0].Username)
- }
-}
-
-func TestBuildReferenceAccountsFromScan_prefersVerified(t *testing.T) {
- got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
- SeedQuery: "轉職",
- Label: "轉職",
- Posts: []placement.ScanCandidate{
- {Author: "plain_user", Text: "轉職技巧", SearchTag: "轉職", LikeCount: 22, ReplyCount: 3, EngagementScore: 55},
- {Author: "blue_user", Text: "轉職分享", SearchTag: "轉職", LikeCount: 14, ReplyCount: 2, EngagementScore: 42, AuthorVerified: true},
- },
- Limit: 5,
- })
- if len(got) < 2 {
- t.Fatalf("expected 2 accounts, got %d", len(got))
- }
- if got[0].Username != "blue_user" || !got[0].AuthorVerified {
- t.Fatalf("verified author should rank first, got %+v", got[0])
- }
-}
-
-func TestBuildReferenceAccountsFromScan_requiresTopicMatch(t *testing.T) {
- posts := []placement.ScanCandidate{
- {Author: "off_topic", Text: "今天天氣真好", SearchTag: "天氣", LikeCount: 40, ReplyCount: 8, EngagementScore: 90},
- }
- got := BuildReferenceAccountsFromScan(ReferenceAccountInput{
- SeedQuery: "轉職",
- Label: "轉職",
- Posts: posts,
- Limit: 5,
- })
- if len(got) != 0 {
- t.Fatalf("expected no accounts for off-topic post, got %d", len(got))
- }
-}
diff --git a/old/backend/internal/library/viral/replicate.go b/old/backend/internal/library/viral/replicate.go
deleted file mode 100644
index ef01504..0000000
--- a/old/backend/internal/library/viral/replicate.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package viral
-
-import (
- "encoding/json"
- "fmt"
- "regexp"
- "strings"
-
- "haixun-backend/internal/library/copyvoice"
- "haixun-backend/internal/library/threadspost"
-)
-
-var codeFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-type ReplicateInput struct {
- TopicLabel string
- TopicBrief string
- Persona string
- StyleProfile string
- OriginalText string
- AuthorName string
- StructureAnalysis string
- NarrativeSeed string
-}
-
-type ReplicateResult struct {
- Angle string `json:"angle"`
- Hook string `json:"hook"`
- Text string `json:"text"`
- Rationale string `json:"rationale"`
- StructureNotes string `json:"structureNotes"`
-}
-
-func BuildSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads / Instagram 創作者。讀完參考爆文後,用指定人設的語言指紋寫一篇全新貼文。
-
-` + copyvoice.SystemRules() + `
-
-輸出:
-- text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)
-- 參考原文只學情緒力度、資訊密度與節奏;最後必須換成人設會講出口的話
-- 篇幅、行數、標點與換行優先遵守人設語言指紋;沒有指紋時才貼近參考原文
-- text 排版要像真人手機發文:可使用自然標點、空行、列點與少量 emoji;不要完全無標點,也不要每句硬換行,除非人設樣本就是這樣
-- 只回傳一個 JSON 物件,欄位:angle, hook, text, rationale, structureNotes`)
-}
-
-func BuildUserPrompt(input ReplicateInput) string {
- var b strings.Builder
- b.WriteString("主題:")
- b.WriteString(strings.TrimSpace(input.TopicLabel))
- b.WriteString("\n")
- if brief := strings.TrimSpace(input.TopicBrief); brief != "" {
- b.WriteString("Brief:")
- b.WriteString(brief)
- b.WriteString("\n")
- }
- if persona := strings.TrimSpace(input.Persona); persona != "" {
- b.WriteString("\n人設定位、語言指紋與禁忌(最高優先):\n")
- b.WriteString(persona)
- b.WriteString("\n")
- b.WriteString(copyvoice.PersonaCalibrationNote())
- b.WriteString("\n")
- }
- if style := strings.TrimSpace(input.StyleProfile); style != "" {
- b.WriteString("\n8D 風格策略:\n")
- b.WriteString(style)
- b.WriteString("\n")
- }
- if analysis := strings.TrimSpace(input.StructureAnalysis); analysis != "" {
- b.WriteString("\n敘事分析(只學故事感,不要套句型或段落公式):\n")
- b.WriteString(analysis)
- b.WriteString("\n")
- }
- author := strings.TrimSpace(input.AuthorName)
- if author == "" {
- author = "匿名"
- }
- original := strings.TrimSpace(input.OriginalText)
- if note := threadspost.MimicLengthGuidance(original); note != "" {
- b.WriteString("\n")
- b.WriteString(note)
- b.WriteString("\n")
- }
- b.WriteString("\n原文參考(@")
- b.WriteString(author)
- b.WriteString(",只感受情緒、敘事與篇幅,禁止抄句型):\n")
- b.WriteString(original)
- b.WriteString("\n\n本次敘事切入:")
- b.WriteString(copyvoice.NarrativeLens(input.NarrativeSeed))
- b.WriteString("\n\n請產出一篇可發布的全新貼文 JSON。hook 欄位只放內部分析,不要把模板句型寫進 text。")
- return b.String()
-}
-
-func ParseReplicateOutput(raw string) (ReplicateResult, error) {
- payload, err := extractJSONObject(raw)
- if err != nil {
- return ReplicateResult{}, err
- }
- var out ReplicateResult
- if err := json.Unmarshal(payload, &out); err != nil {
- return ReplicateResult{}, fmt.Errorf("parse viral replica json: %w", err)
- }
- out.Text = threadspost.FormatDraftText(out.Text)
- if out.Text == "" {
- return ReplicateResult{}, fmt.Errorf("replica text missing")
- }
- out.Angle = strings.TrimSpace(out.Angle)
- out.Hook = strings.TrimSpace(out.Hook)
- out.Rationale = strings.TrimSpace(out.Rationale)
- out.StructureNotes = strings.TrimSpace(out.StructureNotes)
- return out, nil
-}
-
-func extractJSONObject(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := codeFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("replica output missing json object")
- }
- return []byte(raw[start : end+1]), nil
-}
diff --git a/old/backend/internal/library/viral/research_map.go b/old/backend/internal/library/viral/research_map.go
deleted file mode 100644
index 4d51156..0000000
--- a/old/backend/internal/library/viral/research_map.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package viral
-
-import (
- "encoding/json"
- "fmt"
- "regexp"
- "strings"
-)
-
-type CopyResearchMap struct {
- AudienceSummary string `json:"audienceSummary"`
- ContentGoal string `json:"contentGoal"`
- Questions []string `json:"questions"`
- Pillars []string `json:"pillars"`
- Exclusions []string `json:"exclusions"`
- SuggestedTags []string `json:"suggestedTags"`
- BenchmarkNotes string `json:"benchmarkNotes"`
-}
-
-type CopyResearchMapInput struct {
- Label string
- SeedQuery string
- Brief string
- Persona string
- StyleBenchmark string
- PersonaAudienceSummary string
- PersonaContentGoal string
- PersonaQuestions []string
- PersonaPillars []string
-}
-
-var copyMapFenceRE = regexp.MustCompile("(?s)```(?:json)?\\s*([\\s\\S]*?)```")
-
-func BuildCopyResearchMapSystemPrompt() string {
- return strings.TrimSpace(`你是 Threads 爆款/對標研究顧問。目標是幫創作者找到「高互動、值得仿寫」的參考貼文與對標方向。
-
-規則:
-1. audienceSummary:必填,2~4 句描述「受眾是誰」(情境、痛點、會搜什麼)
-2. contentGoal 要寫:找到近期互動佳、結構可模仿的爆款貼文,分析 hook/節奏/文案公式
-3. pillars:可模仿的內容方向(語錄型、故事型、清單型等),至少 4 個
-4. questions:受眾會搜尋/關心的短問題,5+ 個,適合當爆款掃描關鍵字
-5. exclusions:不要模仿的內容(業配、純晒照、無結構閒聊等),至少 4 個
-6. suggestedTags:2~4 字短詞,10 個左右,用於 Threads 搜尋爆款
-7. benchmarkNotes:一句話說明怎樣算「值得仿的爆款」(互動、留言品質、hook 清楚)
-8. 繁體中文;只回傳 JSON:audienceSummary, contentGoal, questions, pillars, exclusions, suggestedTags, benchmarkNotes`)
-}
-
-func BuildCopyResearchMapUserPrompt(in CopyResearchMapInput) string {
- var b strings.Builder
- b.WriteString("【主題】")
- b.WriteString(strings.TrimSpace(in.Label))
- b.WriteString("\n【種子關鍵字】")
- b.WriteString(strings.TrimSpace(in.SeedQuery))
- b.WriteString("\n【Brief】\n")
- b.WriteString(strings.TrimSpace(in.Brief))
- if p := strings.TrimSpace(in.Persona); p != "" {
- b.WriteString("\n【人設】\n")
- b.WriteString(p)
- }
- if bench := strings.TrimSpace(in.StyleBenchmark); bench != "" {
- b.WriteString("\n【對標帳號】@")
- b.WriteString(strings.TrimPrefix(bench, "@"))
- b.WriteString("\n")
- }
- b.WriteString("\n請產出拷貝忍者研究地圖 JSON。")
- return b.String()
-}
-
-func ParseCopyResearchMapOutput(raw string) (CopyResearchMap, error) {
- payload, err := extractCopyMapJSON(raw)
- if err != nil {
- return CopyResearchMap{}, err
- }
- var root map[string]json.RawMessage
- if err := json.Unmarshal(payload, &root); err != nil {
- return CopyResearchMap{}, fmt.Errorf("parse copy research map: %w", err)
- }
- tagObjs := parseFlexibleSuggestedTags(pickRawMessage(root, "suggestedTags", "suggested_tags"))
- tagStrs := make([]string, 0, len(tagObjs))
- for _, item := range tagObjs {
- if item.Tag != "" {
- tagStrs = append(tagStrs, item.Tag)
- }
- }
- out := CopyResearchMap{
- AudienceSummary: firstJSONString(root, "audienceSummary", "audience_summary"),
- ContentGoal: firstJSONString(root, "contentGoal", "content_goal"),
- BenchmarkNotes: firstJSONString(root, "benchmarkNotes", "benchmark_notes"),
- Questions: parseFlexibleStringList(pickRawMessage(root, "questions")),
- Pillars: parseFlexibleStringList(pickRawMessage(root, "pillars")),
- Exclusions: parseFlexibleStringList(pickRawMessage(root, "exclusions")),
- SuggestedTags: cleanLines(tagStrs),
- }
- if strings.TrimSpace(out.AudienceSummary) == "" {
- return CopyResearchMap{}, fmt.Errorf("copy research map missing audienceSummary")
- }
- return out, nil
-}
-
-func ToEntityResearchMap(m CopyResearchMap) map[string]any {
- return map[string]any{
- "audience_summary": m.AudienceSummary,
- "content_goal": m.ContentGoal,
- "questions": m.Questions,
- "pillars": m.Pillars,
- "exclusions": m.Exclusions,
- "suggested_tags": m.SuggestedTags,
- "benchmark_notes": m.BenchmarkNotes,
- }
-}
-
-func cleanLines(items []string) []string {
- out := make([]string, 0, len(items))
- seen := map[string]struct{}{}
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- if _, ok := seen[item]; ok {
- continue
- }
- seen[item] = struct{}{}
- out = append(out, item)
- }
- return out
-}
-
-func extractCopyMapJSON(raw string) ([]byte, error) {
- raw = strings.TrimSpace(raw)
- if m := copyMapFenceRE.FindStringSubmatch(raw); len(m) == 2 {
- raw = strings.TrimSpace(m[1])
- }
- start := strings.Index(raw, "{")
- end := strings.LastIndex(raw, "}")
- if start < 0 || end <= start {
- return nil, fmt.Errorf("copy research map missing json")
- }
- return []byte(raw[start : end+1]), nil
-}
diff --git a/old/backend/internal/library/viral/score.go b/old/backend/internal/library/viral/score.go
deleted file mode 100644
index 2735c2b..0000000
--- a/old/backend/internal/library/viral/score.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package viral
-
-import (
- "strings"
-
- "haixun-backend/internal/library/placement"
-)
-
-const (
- MinEngagementScore = 24
- MinLikeCount = 8
- HotEngagementScore = 80
-)
-
-// ScorePost ranks viral imitation candidates by engagement signals.
-func ScorePost(likes, replies int) int {
- if likes < 0 {
- likes = 0
- }
- if replies < 0 {
- replies = 0
- }
- score := likes*2 + replies*3
- if likes >= 20 && replies >= 3 {
- score += 15
- }
- if replies > 0 && likes > 0 && replies*3 >= likes {
- score += 10
- }
- if score > 100 {
- return 100
- }
- return score
-}
-
-func PriorityLabel(score int) string {
- if score >= HotEngagementScore {
- return "hot"
- }
- if score >= MinEngagementScore {
- return "warm"
- }
- return "cold"
-}
-
-func PassesViralCandidate(text string, likes, replies, engagementScore int, exclusions []string) bool {
- if strings.TrimSpace(text) == "" {
- return false
- }
- if placement.MatchesExclusion(text, exclusions) {
- return false
- }
- if placement.LooksLikeCasualChat(text) && !hasViralSignal(text) {
- return false
- }
- if likes < MinLikeCount && engagementScore < MinEngagementScore {
- return false
- }
- if engagementScore < MinEngagementScore {
- return false
- }
- return true
-}
-
-func hasViralSignal(text string) bool {
- text = strings.ToLower(text)
- signals := []string{"分享", "推薦", "心得", "技巧", "方法", "語錄", "故事", "怎麼", "為什麼", "必看"}
- for _, s := range signals {
- if strings.Contains(text, s) {
- return true
- }
- }
- return len([]rune(text)) >= 40
-}
diff --git a/old/backend/internal/library/viral/search_tags.go b/old/backend/internal/library/viral/search_tags.go
deleted file mode 100644
index ac1a569..0000000
--- a/old/backend/internal/library/viral/search_tags.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package viral
-
-import (
- "strings"
-)
-
-const (
- DefaultSelectedTagCount = 5
- MaxScanTags = 6
-)
-
-type SuggestedTag struct {
- Tag string `json:"tag"`
- Reason string `json:"reason,omitempty"`
- SearchIntent string `json:"searchIntent,omitempty"`
- SearchType string `json:"searchType,omitempty"`
-}
-
-// PickDefaultSelectedTags chooses a lean set of search queries. Prefer complete
-// intent-preserving queries over short hashtags; short generic tags tend to
-// drift away from niche copy-mission topics.
-func PickDefaultSelectedTags(tags []SuggestedTag) []string {
- short := []string{}
- query := []string{}
- contextual := []string{}
- account := []string{}
-
- for _, item := range tags {
- tag := strings.TrimSpace(item.Tag)
- if tag == "" {
- continue
- }
- st := strings.TrimSpace(item.SearchType)
- if strings.HasPrefix(tag, "@") || st == "帳號" {
- account = append(account, tag)
- continue
- }
- switch st {
- case "查詢", "情境":
- query = append(query, tag)
- case "短詞", "語錄":
- if len([]rune(tag)) >= 6 {
- query = append(query, tag)
- } else {
- short = append(short, tag)
- }
- default:
- if len([]rune(tag)) <= 4 {
- short = append(short, tag)
- } else {
- contextual = append(contextual, tag)
- }
- }
- }
-
- out := []string{}
- seen := map[string]struct{}{}
- add := func(tag string) {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return
- }
- if _, ok := seen[tag]; ok {
- return
- }
- seen[tag] = struct{}{}
- out = append(out, tag)
- }
- for _, tag := range query {
- if len(out) >= 4 {
- break
- }
- add(tag)
- }
- for _, tag := range contextual {
- if len(out) >= 4 {
- break
- }
- add(tag)
- }
- for _, tag := range account {
- if len(out) >= DefaultSelectedTagCount {
- break
- }
- add(tag)
- }
- for _, tag := range query {
- if len(out) >= DefaultSelectedTagCount {
- break
- }
- add(tag)
- }
- for _, tag := range contextual {
- if len(out) >= DefaultSelectedTagCount {
- break
- }
- add(tag)
- }
- for _, tag := range short {
- if len(out) >= DefaultSelectedTagCount {
- break
- }
- add(tag)
- }
- if len(out) > MaxScanTags {
- out = out[:MaxScanTags]
- }
- return out
-}
-
-func TagStrings(tags []SuggestedTag) []string {
- out := make([]string, 0, len(tags))
- for _, item := range tags {
- if tag := strings.TrimSpace(item.Tag); tag != "" {
- out = append(out, tag)
- }
- }
- return out
-}
diff --git a/old/backend/internal/library/viral/search_tags_test.go b/old/backend/internal/library/viral/search_tags_test.go
deleted file mode 100644
index ce2c01c..0000000
--- a/old/backend/internal/library/viral/search_tags_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package viral
-
-import "testing"
-
-func TestPickDefaultSelectedTags(t *testing.T) {
- tags := []SuggestedTag{
- {Tag: "轉職", SearchType: "短詞"},
- {Tag: "面試焦慮", SearchType: "短詞"},
- {Tag: "被裁怎麼辦", SearchType: "情境"},
- {Tag: "30歲轉職真實心得", SearchType: "情境"},
- {Tag: "努力不一定成功", SearchType: "語錄"},
- {Tag: "@powerfulceo", SearchType: "帳號"},
- {Tag: "薪水談判", SearchType: "短詞"},
- }
- out := PickDefaultSelectedTags(tags)
- if len(out) == 0 {
- t.Fatal("expected selected tags")
- }
- if len(out) > DefaultSelectedTagCount {
- t.Fatalf("expected at most %d tags, got %d", DefaultSelectedTagCount, len(out))
- }
- seen := map[string]struct{}{}
- for _, tag := range out {
- if _, ok := seen[tag]; ok {
- t.Fatalf("duplicate tag %q", tag)
- }
- seen[tag] = struct{}{}
- }
-}
-
-func TestPickDefaultSelectedTags_prefersIntentPreservingQueries(t *testing.T) {
- tags := []SuggestedTag{
- {Tag: "老公吃什麼", SearchType: "短詞"},
- {Tag: "多閱讀", SearchType: "短詞"},
- {Tag: "備孕男生保健品", SearchType: "查詢"},
- {Tag: "精蟲品質吃什麼", SearchType: "查詢"},
- {Tag: "男性備孕葉酸鋅", SearchType: "情境"},
- }
- out := PickDefaultSelectedTags(tags)
- if len(out) < 2 {
- t.Fatalf("expected selected queries, got %#v", out)
- }
- if out[0] != "備孕男生保健品" || out[1] != "精蟲品質吃什麼" {
- t.Fatalf("expected complete intent queries first, got %#v", out)
- }
-}
diff --git a/old/backend/internal/library/viral/tags.go b/old/backend/internal/library/viral/tags.go
deleted file mode 100644
index 67de259..0000000
--- a/old/backend/internal/library/viral/tags.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package viral
-
-import "strings"
-
-func IsAccountTag(tag string) bool {
- tag = strings.TrimSpace(tag)
- return strings.HasPrefix(tag, "@") && len(tag) > 1
-}
-
-func NormalizeAccountTag(tag string) string {
- tag = strings.TrimSpace(tag)
- tag = strings.TrimPrefix(tag, "@")
- return strings.TrimSpace(tag)
-}
-
-func DiscoverKeywordFromTag(tag string) string {
- tag = strings.TrimSpace(tag)
- if IsAccountTag(tag) {
- return NormalizeAccountTag(tag)
- }
- return tag
-}
diff --git a/old/backend/internal/library/viral/tags_test.go b/old/backend/internal/library/viral/tags_test.go
deleted file mode 100644
index 7465faa..0000000
--- a/old/backend/internal/library/viral/tags_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package viral
-
-import "testing"
-
-func TestIsAccountTag(t *testing.T) {
- if !IsAccountTag("@creator") {
- t.Fatal("expected account tag")
- }
- if IsAccountTag("keyword") {
- t.Fatal("expected keyword tag")
- }
-}
-
-func TestDiscoverKeywordFromTag(t *testing.T) {
- if got := DiscoverKeywordFromTag("@foo_bar"); got != "foo_bar" {
- t.Fatalf("got %q", got)
- }
- if got := DiscoverKeywordFromTag("熱詞"); got != "熱詞" {
- t.Fatalf("got %q", got)
- }
-}
diff --git a/old/backend/internal/library/webpage/digest.go b/old/backend/internal/library/webpage/digest.go
deleted file mode 100644
index a36f6ce..0000000
--- a/old/backend/internal/library/webpage/digest.go
+++ /dev/null
@@ -1,182 +0,0 @@
-package webpage
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
-
- "github.com/go-shiori/go-readability"
-)
-
-const (
- DefaultMaxPages = 6
- DefaultMaxMarkdown = 6000
- DefaultPerPageChars = 2500
- DefaultFetchTimeout = 18 * time.Second
- DefaultMaxBodyBytes = 2 << 20
-)
-
-type FetchOptions struct {
- MaxPages int
- MaxMarkdown int
- PerPageChars int
- Timeout time.Duration
- UserAgent string
-}
-
-func (o FetchOptions) normalized() FetchOptions {
- out := o
- if out.MaxPages <= 0 {
- out.MaxPages = DefaultMaxPages
- }
- if out.MaxMarkdown <= 0 {
- out.MaxMarkdown = DefaultMaxMarkdown
- }
- if out.PerPageChars <= 0 {
- out.PerPageChars = DefaultPerPageChars
- }
- if out.Timeout <= 0 {
- out.Timeout = DefaultFetchTimeout
- }
- if strings.TrimSpace(out.UserAgent) == "" {
- out.UserAgent = "ThreadMasterKnowledgeBot/1.0 (+https://thread-master.local)"
- }
- return out
-}
-
-type Digest struct {
- URL string
- Title string
- Markdown string
- Error string
-}
-
-// FetchDigests fetches readable page content and formats each page as lightweight markdown.
-func FetchDigests(ctx context.Context, urls []string, opts FetchOptions) []Digest {
- opts = opts.normalized()
- seen := map[string]struct{}{}
- out := make([]Digest, 0, min(len(urls), opts.MaxPages))
- for _, raw := range urls {
- if len(out) >= opts.MaxPages {
- break
- }
- u := strings.TrimSpace(raw)
- if u == "" {
- continue
- }
- key := strings.ToLower(u)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- if !FetchableURL(u) {
- continue
- }
- out = append(out, fetchOne(ctx, u, opts))
- }
- return out
-}
-
-func fetchOne(ctx context.Context, pageURL string, opts FetchOptions) Digest {
- d := Digest{URL: pageURL}
- reqCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
- defer cancel()
-
- req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil)
- if err != nil {
- d.Error = err.Error()
- return d
- }
- req.Header.Set("User-Agent", opts.UserAgent)
- req.Header.Set("Accept", "text/html,application/xhtml+xml")
-
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- d.Error = err.Error()
- return d
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- d.Error = fmt.Sprintf("http %d", resp.StatusCode)
- return d
- }
-
- body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxBodyBytes))
- if err != nil {
- d.Error = err.Error()
- return d
- }
- parsed, err := url.Parse(pageURL)
- if err != nil {
- d.Error = err.Error()
- return d
- }
- article, err := readability.FromReader(strings.NewReader(string(body)), parsed)
- if err != nil {
- d.Error = err.Error()
- return d
- }
- title := strings.TrimSpace(article.Title)
- text := strings.TrimSpace(article.TextContent)
- if title == "" && text == "" {
- d.Error = "empty article"
- return d
- }
- d.Title = title
- d.Markdown = toMarkdown(title, text, opts.PerPageChars)
- return d
-}
-
-func toMarkdown(title, text string, maxChars int) string {
- var b strings.Builder
- if title != "" {
- b.WriteString("# ")
- b.WriteString(title)
- b.WriteString("\n\n")
- }
- if text != "" {
- b.WriteString(text)
- }
- out := strings.TrimSpace(b.String())
- if maxChars > 0 && len([]rune(out)) > maxChars {
- runes := []rune(out)
- out = strings.TrimSpace(string(runes[:maxChars])) + "…"
- }
- return out
-}
-
-// FetchableURL returns false for unsupported or crawler-owned hosts.
-func FetchableURL(raw string) bool {
- u, err := url.Parse(strings.TrimSpace(raw))
- if err != nil || u.Scheme == "" || u.Host == "" {
- return false
- }
- switch strings.ToLower(u.Scheme) {
- case "http", "https":
- default:
- return false
- }
- host := strings.ToLower(u.Hostname())
- if host == "localhost" || strings.HasSuffix(host, ".local") {
- return false
- }
- if strings.Contains(host, "threads.net") || strings.Contains(host, "threads.com") {
- return false
- }
- path := strings.ToLower(u.Path)
- if strings.HasSuffix(path, ".pdf") || strings.HasSuffix(path, ".zip") {
- return false
- }
- return true
-}
-
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/old/backend/internal/library/webpage/digest_test.go b/old/backend/internal/library/webpage/digest_test.go
deleted file mode 100644
index e66b279..0000000
--- a/old/backend/internal/library/webpage/digest_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package webpage
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-)
-
-func TestFetchableURL(t *testing.T) {
- if FetchableURL("https://example.com/article") != true {
- t.Fatal("expected fetchable")
- }
- if FetchableURL("https://www.threads.net/@foo") != false {
- t.Fatal("threads should be skipped")
- }
- if FetchableURL("ftp://example.com") != false {
- t.Fatal("ftp should be skipped")
- }
-}
-
-func TestFetchDigests_ExtractsMarkdown(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _, _ = w.Write([]byte(`備孕指南備孕指南
葉酸與鋅很重要,作息要穩定。
`))
- }))
- defer srv.Close()
-
- digests := FetchDigests(context.Background(), []string{srv.URL}, FetchOptions{MaxPages: 1})
- if len(digests) != 1 {
- t.Fatalf("digests = %v", digests)
- }
- if digests[0].Error != "" {
- t.Fatalf("unexpected error: %s", digests[0].Error)
- }
- if digests[0].Markdown == "" {
- t.Fatal("expected markdown")
- }
- if !strings.Contains(digests[0].Markdown, "備孕指南") || !strings.Contains(digests[0].Markdown, "葉酸") {
- t.Fatalf("markdown = %q", digests[0].Markdown)
- }
-}
diff --git a/old/backend/internal/library/webpage/format.go b/old/backend/internal/library/webpage/format.go
deleted file mode 100644
index 2f7126c..0000000
--- a/old/backend/internal/library/webpage/format.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package webpage
-
-import (
- "strings"
-)
-
-// FormatMarkdownBlock renders digests for LLM prompts.
-func FormatMarkdownBlock(digests []Digest, maxTotal int) string {
- if len(digests) == 0 {
- return ""
- }
- if maxTotal <= 0 {
- maxTotal = DefaultMaxMarkdown
- }
- var b strings.Builder
- used := 0
- for i, item := range digests {
- if used >= maxTotal {
- break
- }
- section := formatDigestSection(item)
- if section == "" {
- continue
- }
- if i > 0 {
- b.WriteString("\n\n---\n\n")
- }
- remain := maxTotal - used
- runes := []rune(section)
- if len(runes) > remain {
- section = string(runes[:remain]) + "…"
- }
- b.WriteString(section)
- used += len([]rune(section))
- }
- return strings.TrimSpace(b.String())
-}
-
-func formatDigestSection(item Digest) string {
- if strings.TrimSpace(item.Markdown) != "" {
- var b strings.Builder
- b.WriteString("來源:")
- b.WriteString(item.URL)
- b.WriteString("\n")
- b.WriteString(item.Markdown)
- return strings.TrimSpace(b.String())
- }
- if strings.TrimSpace(item.Error) == "" {
- return ""
- }
- return "來源:" + item.URL + "\n(抓取失敗:" + item.Error + ")"
-}
diff --git a/old/backend/internal/library/websearch/client.go b/old/backend/internal/library/websearch/client.go
deleted file mode 100644
index 5742e29..0000000
--- a/old/backend/internal/library/websearch/client.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package websearch
-
-import (
- "context"
- "strings"
-
- libbrave "haixun-backend/internal/library/brave"
- libexa "haixun-backend/internal/library/exa"
-)
-
-type Provider string
-
-const (
- ProviderBrave Provider = "brave"
- ProviderExa Provider = "exa"
-)
-
-func ParseProvider(raw string) Provider {
- switch Provider(strings.ToLower(strings.TrimSpace(raw))) {
- case ProviderExa:
- return ProviderExa
- default:
- return ProviderBrave
- }
-}
-
-type Mode string
-
-const (
- ModeKnowledgeExpand Mode = "knowledge_expand"
- ModeThreadsDiscover Mode = "threads_discover"
-)
-
-type SearchResult struct {
- Title string
- Snippet string
- URL string
-}
-
-type SearchResponse struct {
- Results []SearchResult
- Query string
- Status string
- Provider Provider
-}
-
-type SearchOptions struct {
- Query string
- Limit int
- Mode Mode
- Country string
- SearchLang string
- UserLocation string
- StartPublishedDate string
-}
-
-type Client interface {
- Search(ctx context.Context, opts SearchOptions) (SearchResponse, error)
- Enabled() bool
- Provider() Provider
-}
-
-type Config struct {
- Provider Provider
- BraveKey string
- ExaKey string
- Country string
- SearchLang string
- UserLocation string
-}
-
-func New(cfg Config) Client {
- switch ParseProvider(string(cfg.Provider)) {
- case ProviderExa:
- return &exaAdapter{
- client: libexa.NewClient(cfg.ExaKey),
- provider: ProviderExa,
- userLocation: cfg.UserLocation,
- }
- default:
- return &braveAdapter{
- client: libbrave.NewClient(cfg.BraveKey),
- provider: ProviderBrave,
- country: cfg.Country,
- searchLang: cfg.SearchLang,
- }
- }
-}
-
-func ConfigFromMember(braveKey, exaKey, provider, country, searchLang, userLocation string) Config {
- return Config{
- Provider: ParseProvider(provider),
- BraveKey: braveKey,
- ExaKey: exaKey,
- Country: country,
- SearchLang: searchLang,
- UserLocation: userLocation,
- }
-}
-
-type braveAdapter struct {
- client *libbrave.Client
- provider Provider
- country string
- searchLang string
-}
-
-func (a *braveAdapter) Provider() Provider { return a.provider }
-
-func (a *braveAdapter) Enabled() bool {
- return a != nil && a.client != nil && a.client.Enabled()
-}
-
-func (a *braveAdapter) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
- mode := libbrave.ModeKnowledgeExpand
- if opts.Mode == ModeThreadsDiscover {
- mode = libbrave.ModeThreadsDiscover
- }
- res, err := a.client.Search(ctx, libbrave.SearchOptions{
- Query: opts.Query,
- Limit: opts.Limit,
- Mode: mode,
- Country: firstNonEmpty(opts.Country, a.country),
- SearchLang: firstNonEmpty(opts.SearchLang, a.searchLang),
- StartPublishedDate: opts.StartPublishedDate,
- })
- return toBraveResponse(res.Query, res.Status, a.provider, res.Results, err)
-}
-
-type exaAdapter struct {
- client *libexa.Client
- provider Provider
- userLocation string
-}
-
-func (a *exaAdapter) Provider() Provider { return a.provider }
-
-func (a *exaAdapter) Enabled() bool {
- return a != nil && a.client != nil && a.client.Enabled()
-}
-
-func (a *exaAdapter) Search(ctx context.Context, opts SearchOptions) (SearchResponse, error) {
- mode := libexa.ModeKnowledgeExpand
- if opts.Mode == ModeThreadsDiscover {
- mode = libexa.ModeThreadsDiscover
- }
- res, err := a.client.Search(ctx, libexa.SearchOptions{
- Query: opts.Query,
- Limit: opts.Limit,
- Mode: mode,
- UserLocation: firstNonEmpty(opts.UserLocation, a.userLocation),
- StartPublishedDate: opts.StartPublishedDate,
- })
- out := SearchResponse{Query: res.Query, Status: res.Status, Provider: a.provider}
- if err != nil {
- return out, err
- }
- for _, item := range res.Results {
- out.Results = append(out.Results, SearchResult{
- Title: item.Title,
- Snippet: item.Snippet,
- URL: item.URL,
- })
- }
- return out, nil
-}
-
-func toBraveResponse(query, status string, provider Provider, items []libbrave.SearchResult, err error) (SearchResponse, error) {
- out := SearchResponse{Query: query, Status: status, Provider: provider}
- if err != nil {
- return out, err
- }
- for _, item := range items {
- out.Results = append(out.Results, SearchResult{
- Title: item.Title,
- Snippet: item.Snippet,
- URL: item.URL,
- })
- }
- return out, nil
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if trimmed := strings.TrimSpace(value); trimmed != "" {
- return trimmed
- }
- }
- return ""
-}
-
-func ActiveAPIKey(cfg Config) string {
- if ParseProvider(string(cfg.Provider)) == ProviderExa {
- return strings.TrimSpace(cfg.ExaKey)
- }
- return strings.TrimSpace(cfg.BraveKey)
-}
-
-func ProviderLabel(provider Provider) string {
- switch provider {
- case ProviderExa:
- return "Exa"
- default:
- return "Brave"
- }
-}
diff --git a/old/backend/internal/logic/ai/actor.go b/old/backend/internal/logic/ai/actor.go
deleted file mode 100644
index 411a715..0000000
--- a/old/backend/internal/logic/ai/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package ai
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/old/backend/internal/logic/ai/chat_logic.go b/old/backend/internal/logic/ai/chat_logic.go
deleted file mode 100644
index 0bdf7b2..0000000
--- a/old/backend/internal/logic/ai/chat_logic.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package ai
-
-import (
- "context"
-
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ChatLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewChatLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatLogic {
- return &ChatLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ChatLogic) Chat(req *types.AIChatReq, token string) (*types.AIChatData, error) {
- system, err := libprompt.AIChatSystem(req.System)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.AI.GenerateText(l.ctx, toGenerateRequest(req, token, system))
- if err != nil {
- return nil, err
- }
-
- return &types.AIChatData{Text: result.Text, FinishReason: result.FinishReason}, nil
-}
diff --git a/old/backend/internal/logic/ai/chat_stream_logic.go b/old/backend/internal/logic/ai/chat_stream_logic.go
deleted file mode 100644
index f432e79..0000000
--- a/old/backend/internal/logic/ai/chat_stream_logic.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package ai
-
-import (
- "context"
-
- libprompt "haixun-backend/internal/library/prompt"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ChatStreamLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewChatStreamLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatStreamLogic {
- return &ChatStreamLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ChatStreamLogic) ChatStream(req *types.AIChatReq, token string) (<-chan domai.StreamEvent, error) {
- system, err := libprompt.AIChatSystem(req.System)
- if err != nil {
- return nil, err
- }
- return l.svcCtx.AI.StreamText(l.ctx, toGenerateRequest(req, token, system))
-}
diff --git a/old/backend/internal/logic/ai/credential.go b/old/backend/internal/logic/ai/credential.go
deleted file mode 100644
index 981630f..0000000
--- a/old/backend/internal/logic/ai/credential.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package ai
-
-import (
- "net/http"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func BearerToken(r *http.Request) (string, error) {
- auth := strings.TrimSpace(r.Header.Get("Authorization"))
- if auth == "" {
- return "", app.For(code.AI).InputMissingRequired("missing Authorization header")
- }
-
- const prefix = "Bearer "
- if !strings.HasPrefix(auth, prefix) {
- return "", app.For(code.Facade).InputInvalidFormat("Authorization must be a Bearer token")
- }
-
- token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
- if token == "" {
- return "", app.For(code.AI).InputMissingRequired("missing AI provider token")
- }
- return token, nil
-}
diff --git a/old/backend/internal/logic/ai/islander_chat_stream_logic.go b/old/backend/internal/logic/ai/islander_chat_stream_logic.go
deleted file mode 100644
index 5193dda..0000000
--- a/old/backend/internal/logic/ai/islander_chat_stream_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package ai
-
-import (
- "context"
-
- libprompt "haixun-backend/internal/library/prompt"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type IslanderChatStreamLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewIslanderChatStreamLogic(ctx context.Context, svcCtx *svc.ServiceContext) *IslanderChatStreamLogic {
- return &IslanderChatStreamLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *IslanderChatStreamLogic) ChatStream(req *types.IslanderChatReq) (<-chan domai.StreamEvent, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- system, err := libprompt.IslanderSystem(req.Context)
- if err != nil {
- return nil, err
- }
- return l.svcCtx.AI.StreamText(l.ctx, toCredentialGenerateRequest(req.Messages, credential, system))
-}
diff --git a/old/backend/internal/logic/ai/list_ai_provider_models_logic.go b/old/backend/internal/logic/ai/list_ai_provider_models_logic.go
deleted file mode 100644
index c9cd6df..0000000
--- a/old/backend/internal/logic/ai/list_ai_provider_models_logic.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package ai
-
-import (
- "context"
-
- "haixun-backend/internal/model/ai/domain/enum"
- aiuc "haixun-backend/internal/model/ai/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListAIProviderModelsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListAIProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAIProviderModelsLogic {
- return &ListAIProviderModelsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListAIProviderModelsLogic) ListAIProviderModels(req *types.AIProviderPath, token string) (*types.AIProviderModelsData, error) {
- result := l.svcCtx.AI.ListProviderModels(l.ctx, enum.ProviderID(req.Provider), aiuc.Credential{APIKey: token})
- return &types.AIProviderModelsData{
- ID: result.ID,
- Label: result.Label,
- Models: result.Models,
- Streams: result.Streams,
- Error: result.Error,
- }, nil
-}
diff --git a/old/backend/internal/logic/ai/list_ai_providers_logic.go b/old/backend/internal/logic/ai/list_ai_providers_logic.go
deleted file mode 100644
index 54e59cf..0000000
--- a/old/backend/internal/logic/ai/list_ai_providers_logic.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package ai
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListAIProvidersLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListAIProvidersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAIProvidersLogic {
- return &ListAIProvidersLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListAIProvidersLogic) ListAIProviders() (*types.AIProvidersData, error) {
- options := l.svcCtx.AI.ListProviders(l.ctx)
- items := make([]types.AIProviderOption, 0, len(options))
- for _, option := range options {
- items = append(items, types.AIProviderOption{
- ID: option.ID,
- Label: option.Label,
- Streams: option.Streams,
- })
- }
- return &types.AIProvidersData{Providers: items}, nil
-}
diff --git a/old/backend/internal/logic/ai/mapper.go b/old/backend/internal/logic/ai/mapper.go
deleted file mode 100644
index 3cce202..0000000
--- a/old/backend/internal/logic/ai/mapper.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package ai
-
-import (
- "haixun-backend/internal/model/ai/domain/enum"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- threadsdom "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toCredentialGenerateRequest(
- messages []types.AIMessage,
- credential *threadsdom.WorkerAiCredential,
- system string,
-) domai.GenerateRequest {
- domMessages := make([]domai.Message, 0, len(messages))
- for _, msg := range messages {
- domMessages = append(domMessages, domai.Message{
- Role: msg.Role,
- Content: msg.Content,
- })
- }
- provider := ""
- model := ""
- apiKey := ""
- if credential != nil {
- provider = credential.Provider
- model = credential.Model
- apiKey = credential.APIKey
- }
- return domai.GenerateRequest{
- Provider: enum.ProviderID(provider),
- Model: model,
- Credential: domai.Credential{
- APIKey: apiKey,
- },
- System: system,
- Messages: domMessages,
- }
-}
-
-func toGenerateRequest(req *types.AIChatReq, token string, system string) domai.GenerateRequest {
- messages := make([]domai.Message, 0, len(req.Messages))
- for _, msg := range req.Messages {
- messages = append(messages, domai.Message{
- Role: msg.Role,
- Content: msg.Content,
- })
- }
- return domai.GenerateRequest{
- Provider: enum.ProviderID(req.Provider),
- Model: req.Model,
- Credential: domai.Credential{
- APIKey: token,
- },
- System: system,
- Messages: messages,
- Temperature: req.Temperature,
- MaxTokens: req.MaxTokens,
- }
-}
diff --git a/old/backend/internal/logic/auth/email_verification.go b/old/backend/internal/logic/auth/email_verification.go
deleted file mode 100644
index 89314b3..0000000
--- a/old/backend/internal/logic/auth/email_verification.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package auth
-
-import (
- "context"
- "crypto/rand"
- "fmt"
- "math/big"
- "time"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/member/domain/entity"
- "haixun-backend/internal/svc"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-const emailVerificationTTL = 15 * time.Minute
-const emailVerificationResendCooldown = 60 * time.Second
-
-func newVerifyCode() (string, error) {
- n, err := rand.Int(rand.Reader, big.NewInt(1000000))
- if err != nil {
- return "", err
- }
- return fmt.Sprintf("%06d", n.Int64()), nil
-}
-
-func requiresNativeEmailVerification(member *entity.Member) bool {
- if member == nil {
- return false
- }
- return member.Origin == "" || member.Origin == entity.OriginNative
-}
-
-func issueAndSendEmailVerification(ctx context.Context, svcCtx *svc.ServiceContext, member *entity.Member) error {
- verifyCode, err := newVerifyCode()
- if err != nil {
- return err
- }
- expiresAt := clock.NowUnixNano() + int64(emailVerificationTTL)
- if err := svcCtx.Member.SetEmailVerificationCode(ctx, member.TenantID, member.UID, verifyCode, expiresAt); err != nil {
- return err
- }
- if err := sendEmailVerification(ctx, svcCtx, member.Email, verifyCode); err != nil {
- logx.WithContext(ctx).Errorf("send verification email failed: uid=%s err=%v", member.UID, err)
- }
- return nil
-}
-
-func sendEmailVerification(ctx context.Context, svcCtx *svc.ServiceContext, email, verifyCode string) error {
- if svcCtx.Mailer == nil {
- return nil
- }
- body := fmt.Sprintf("你的巡樓 Console 驗證碼是:%s\n\n此驗證碼 15 分鐘內有效。", verifyCode)
- if err := svcCtx.Mailer.Send(ctx, email, "巡樓 Console 帳號驗證碼", body); err != nil {
- logx.WithContext(ctx).Errorf("send verification email failed: email=%s err=%v", email, err)
- return err
- }
- return nil
-}
-
-func maybeSendLoginVerificationEmail(ctx context.Context, svcCtx *svc.ServiceContext, member *entity.Member) {
- if member == nil || !requiresNativeEmailVerification(member) || member.EmailVerified {
- return
- }
- if member.EmailVerifyCode != "" && member.EmailVerifyExpiresAt > clock.NowUnixNano() {
- return
- }
- _ = issueAndSendEmailVerification(ctx, svcCtx, member)
-}
-
-func emailVerificationSentRecently(member *entity.Member) bool {
- if member == nil || member.EmailVerifyExpiresAt <= 0 {
- return false
- }
- sentAt := member.EmailVerifyExpiresAt - int64(emailVerificationTTL)
- return clock.NowUnixNano()-sentAt < int64(emailVerificationResendCooldown)
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/auth/login_logic.go b/old/backend/internal/logic/auth/login_logic.go
deleted file mode 100644
index 8816674..0000000
--- a/old/backend/internal/logic/auth/login_logic.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package auth
-
-import (
- "context"
-
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type LoginLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
- return &LoginLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *LoginLogic) Login(req *types.AuthLoginReq) (*types.AuthTokenData, error) {
- member, token, err := l.svcCtx.Member.Login(l.ctx, memberusecase.LoginRequest{
- TenantID: req.TenantID,
- Email: req.Email,
- Password: req.Password,
- })
- if err != nil {
- return nil, err
- }
- maybeSendLoginVerificationEmail(l.ctx, l.svcCtx, member)
- return toAuthTokenData(token), nil
-}
diff --git a/old/backend/internal/logic/auth/logout_logic.go b/old/backend/internal/logic/auth/logout_logic.go
deleted file mode 100644
index 71b28e4..0000000
--- a/old/backend/internal/logic/auth/logout_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package auth
-
-import (
- "context"
-
- "haixun-backend/internal/middleware"
- authusecase "haixun-backend/internal/model/auth/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type LogoutLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutLogic {
- return &LogoutLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *LogoutLogic) Logout(authorization string) (*types.LogoutData, error) {
- err := l.svcCtx.AuthToken.Logout(l.ctx, authusecase.LogoutRequest{
- AccessToken: middleware.BearerToken(authorization),
- })
- if err != nil {
- return nil, err
- }
- return &types.LogoutData{OK: true}, nil
-}
diff --git a/old/backend/internal/logic/auth/mapper.go b/old/backend/internal/logic/auth/mapper.go
deleted file mode 100644
index 3aaf83e..0000000
--- a/old/backend/internal/logic/auth/mapper.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package auth
-
-import (
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toAuthTokenData(token *memberusecase.AuthToken) *types.AuthTokenData {
- if token == nil {
- return nil
- }
- return &types.AuthTokenData{
- AccessToken: token.AccessToken,
- RefreshToken: token.RefreshToken,
- ExpiresIn: token.ExpiresIn,
- UID: token.UID,
- TokenType: token.TokenType,
- }
-}
diff --git a/old/backend/internal/logic/auth/refresh_logic.go b/old/backend/internal/logic/auth/refresh_logic.go
deleted file mode 100644
index 2f1df20..0000000
--- a/old/backend/internal/logic/auth/refresh_logic.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package auth
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type RefreshLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRefreshLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefreshLogic {
- return &RefreshLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *RefreshLogic) Refresh(req *types.AuthRefreshReq) (*types.AuthTokenData, error) {
- pair, err := l.svcCtx.AuthToken.Refresh(l.ctx, req.RefreshToken)
- if err != nil {
- return nil, err
- }
- return &types.AuthTokenData{
- AccessToken: pair.AccessToken,
- RefreshToken: pair.RefreshToken,
- ExpiresIn: pair.ExpiresIn,
- UID: pair.UID,
- TokenType: pair.TokenType,
- }, nil
-}
diff --git a/old/backend/internal/logic/auth/register_logic.go b/old/backend/internal/logic/auth/register_logic.go
deleted file mode 100644
index d4891eb..0000000
--- a/old/backend/internal/logic/auth/register_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package auth
-
-import (
- "context"
-
- "haixun-backend/internal/logic/authz"
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type RegisterLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
- return &RegisterLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *RegisterLogic) Register(req *types.AuthRegisterReq) (*types.AuthTokenData, error) {
- if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- member, token, err := l.svcCtx.Member.Register(l.ctx, memberusecase.RegisterRequest{
- TenantID: req.TenantID,
- Email: req.Email,
- Password: req.Password,
- DisplayName: req.DisplayName,
- Language: req.Language,
- })
- if err != nil {
- return nil, err
- }
- if err := issueAndSendEmailVerification(l.ctx, l.svcCtx, member); err != nil {
- return nil, err
- }
- return toAuthTokenData(token), nil
-}
diff --git a/old/backend/internal/logic/auth/resend_verification_email_logic.go b/old/backend/internal/logic/auth/resend_verification_email_logic.go
deleted file mode 100644
index 8af2482..0000000
--- a/old/backend/internal/logic/auth/resend_verification_email_logic.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package auth
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ResendVerificationEmailLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewResendVerificationEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResendVerificationEmailLogic {
- return &ResendVerificationEmailLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ResendVerificationEmailLogic) ResendVerificationEmail() (*types.AuthResendVerificationData, error) {
- actor, ok := authctx.ActorFromContext(l.ctx)
- if !ok {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if l.svcCtx.Mailer == nil {
- return nil, app.For(code.Auth).SysNotImplemented("smtp mailer is not configured")
- }
- err := l.svcCtx.Member.ResendEmailVerification(l.ctx, actor.TenantID, actor.UID, func(email, verifyCode string) error {
- return sendEmailVerification(l.ctx, l.svcCtx, email, verifyCode)
- })
- if err != nil {
- return nil, err
- }
- return &types.AuthResendVerificationData{Sent: true}, nil
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/auth/verify_email_logic.go b/old/backend/internal/logic/auth/verify_email_logic.go
deleted file mode 100644
index ed56bb0..0000000
--- a/old/backend/internal/logic/auth/verify_email_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package auth
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type VerifyEmailLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewVerifyEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyEmailLogic {
- return &VerifyEmailLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *VerifyEmailLogic) VerifyEmail(req *types.AuthVerifyEmailReq) (*types.AuthVerifyEmailData, error) {
- actor, ok := authctx.ActorFromContext(l.ctx)
- if !ok {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- member, err := l.svcCtx.Member.VerifyEmail(l.ctx, actor.TenantID, actor.UID, req.Code)
- if err != nil {
- return nil, err
- }
- return &types.AuthVerifyEmailData{EmailVerified: member.EmailVerified}, nil
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/authz/admin.go b/old/backend/internal/logic/authz/admin.go
deleted file mode 100644
index 82e4c0f..0000000
--- a/old/backend/internal/logic/authz/admin.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package authz
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
-)
-
-func RequireAdmin(ctx context.Context, svcCtx *svc.ServiceContext) error {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- member, err := svcCtx.Member.GetByUID(ctx, actor.TenantID, actor.UID)
- if err != nil {
- return err
- }
- for _, role := range member.Roles {
- if role == "admin" {
- return nil
- }
- }
- return app.For(code.Auth).AuthForbidden("admin role required")
-}
diff --git a/old/backend/internal/logic/brand/actor.go b/old/backend/internal/logic/brand/actor.go
deleted file mode 100644
index 9eff093..0000000
--- a/old/backend/internal/logic/brand/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package brand
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/old/backend/internal/logic/brand/create_brand_logic.go b/old/backend/internal/logic/brand/create_brand_logic.go
deleted file mode 100644
index 6e3edd9..0000000
--- a/old/backend/internal/logic/brand/create_brand_logic.go
+++ /dev/null
@@ -1,49 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateBrandLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateBrandLogic {
- return &CreateBrandLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreateBrandLogic) CreateBrand(req *types.CreateBrandReq) (resp *types.BrandData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- displayName := ""
- if req != nil {
- displayName = req.DisplayName
- }
- item, err := l.svcCtx.Brand.Create(l.ctx, domusecase.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- DisplayName: displayName,
- })
- if err != nil {
- return nil, err
- }
- out := toBrandData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/brand/create_brand_product_logic.go b/old/backend/internal/logic/brand/create_brand_product_logic.go
deleted file mode 100644
index 76c5184..0000000
--- a/old/backend/internal/logic/brand/create_brand_product_logic.go
+++ /dev/null
@@ -1,49 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateBrandProductLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateBrandProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateBrandProductLogic {
- return &CreateBrandProductLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreateBrandProductLogic) CreateBrandProduct(req *types.CreateBrandProductHandlerReq) (resp *types.BrandProductData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Brand.CreateProduct(l.ctx, domusecase.CreateProductRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- Label: req.Label,
- ProductContext: req.ProductContext,
- PlacementURL: req.PlacementURL,
- MatchTags: req.MatchTags,
- })
- if err != nil {
- return nil, err
- }
- out := toBrandProductData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/brand/delete_brand_logic.go b/old/backend/internal/logic/brand/delete_brand_logic.go
deleted file mode 100644
index 0fb033c..0000000
--- a/old/backend/internal/logic/brand/delete_brand_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeleteBrandLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteBrandLogic {
- return &DeleteBrandLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *DeleteBrandLogic) DeleteBrand(req *types.BrandPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.Brand.Delete(l.ctx, tenantID, uid, req.ID)
-}
diff --git a/old/backend/internal/logic/brand/delete_brand_product_logic.go b/old/backend/internal/logic/brand/delete_brand_product_logic.go
deleted file mode 100644
index 5c959d8..0000000
--- a/old/backend/internal/logic/brand/delete_brand_product_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeleteBrandProductLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteBrandProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteBrandProductLogic {
- return &DeleteBrandProductLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *DeleteBrandProductLogic) DeleteBrandProduct(req *types.BrandProductPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.Brand.DeleteProduct(l.ctx, tenantID, uid, req.ID, req.ProductID)
-}
diff --git a/old/backend/internal/logic/brand/expand_knowledge_graph_logic.go b/old/backend/internal/logic/brand/expand_knowledge_graph_logic.go
deleted file mode 100644
index e4830dd..0000000
--- a/old/backend/internal/logic/brand/expand_knowledge_graph_logic.go
+++ /dev/null
@@ -1,91 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ExpandKnowledgeGraphLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewExpandKnowledgeGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExpandKnowledgeGraphLogic {
- return &ExpandKnowledgeGraphLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ExpandKnowledgeGraphLogic) ExpandKnowledgeGraph(req *types.ExpandKnowledgeGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- seed := strings.TrimSpace(req.SeedQuery)
- supplemental := req.Supplemental
- if seed == "" {
- return nil, app.For(code.Brand).InputMissingRequired("seed_query is required")
- }
-
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- expandStrategy := placement.EffectiveExpandStrategy(research)
- if supplemental && placement.WebSearchAvailable(research) {
- expandStrategy = libkg.ExpandStrategyBrave
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
-
- payload := map[string]any{
- "brand_id": req.ID,
- "seed_query": seed,
- "supplemental": supplemental,
- "regenerate_map": req.RegenerateMap,
- "expand_strategy": expandStrategy.String(),
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "expand-graph",
- Scope: "brand",
- ScopeID: req.ID,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
-
- message := "研究地圖產生中,完成後可檢視延伸知識與參考連結"
-
- return &types.ExpandKnowledgeGraphData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: message,
- }, nil
-}
diff --git a/old/backend/internal/logic/brand/generate_brand_content_matrix_logic.go b/old/backend/internal/logic/brand/generate_brand_content_matrix_logic.go
deleted file mode 100644
index c6b24f6..0000000
--- a/old/backend/internal/logic/brand/generate_brand_content_matrix_logic.go
+++ /dev/null
@@ -1,157 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libmatrix "haixun-backend/internal/library/matrix"
- libprompt "haixun-backend/internal/library/prompt"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- cmatrixusecase "haixun-backend/internal/model/content_matrix/domain/usecase"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GenerateBrandContentMatrixLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGenerateBrandContentMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateBrandContentMatrixLogic {
- return &GenerateBrandContentMatrixLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GenerateBrandContentMatrixLogic) GenerateBrandContentMatrix(req *types.GenerateContentMatrixHandlerReq) (resp *types.ContentMatrixData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- count := 5
- if req.Count > 0 {
- count = req.Count
- }
- if count > 10 {
- count = 10
- }
-
- brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- posts, err := l.svcCtx.ScanPost.List(l.ctx, scanpostusecase.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- Recent7dOnly: true,
- ProductFitMin: 70,
- Limit: 12,
- })
- if err != nil {
- return nil, err
- }
- if len(posts) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("尚無海巡素材,請先執行雙軌海巡")
- }
-
- materials := make([]libmatrix.MaterialPost, 0, len(posts))
- topic := ""
- for _, post := range posts {
- if topic == "" {
- topic = post.SearchTag
- }
- materials = append(materials, libmatrix.MaterialPost{
- SearchTag: post.SearchTag,
- Author: post.Author,
- Text: post.Text,
- Permalink: post.Permalink,
- Priority: post.Priority,
- })
- }
-
- userPrompt, err := libmatrix.BuildUserPrompt(libmatrix.GenerateInput{
- Persona: brand.Brief,
- TopicLabel: topic,
- AudienceBrief: brand.TargetAudience,
- ProductBrief: brand.ProductBrief,
- Posts: materials,
- Count: count,
- })
- if err != nil {
- return nil, app.For(code.AI).SysInternal("matrix user prompt load failed")
- }
- systemPrompt, err := libprompt.MatrixPlacementSystem()
- if err != nil {
- return nil, app.For(code.AI).SysInternal("matrix system prompt load failed")
- }
- if strings.TrimSpace(brand.Brief) != "" {
- systemPrompt = strings.TrimSpace(systemPrompt) + "\n\n品牌簡述:\n" + strings.TrimSpace(brand.Brief)
- }
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- })
- if err != nil {
- return nil, err
- }
- parsed, err := libmatrix.ParseGenerateOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("內容矩陣 LLM 回傳無法解析:" + err.Error())
- }
-
- rows := make([]cmatrixusecase.Row, 0, len(parsed.Rows))
- for _, row := range parsed.Rows {
- rows = append(rows, cmatrixusecase.Row{
- SortOrder: row.SortOrder,
- SearchTag: row.SearchTag,
- Angle: row.Angle,
- Hook: row.Hook,
- Text: row.Text,
- ReferenceNotes: row.ReferenceNotes,
- SourcePermalinks: row.SourcePermalinks,
- Rationale: row.Rationale,
- })
- }
- now := clock.NowUnixNano()
- saved, err := l.svcCtx.ContentMatrix.Upsert(l.ctx, cmatrixusecase.UpsertRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- Rows: rows,
- GeneratedAt: now,
- })
- if err != nil {
- return nil, err
- }
- return toContentMatrixData(saved), nil
-}
diff --git a/old/backend/internal/logic/brand/generate_outreach_drafts_logic.go b/old/backend/internal/logic/brand/generate_outreach_drafts_logic.go
deleted file mode 100644
index ba3de8b..0000000
--- a/old/backend/internal/logic/brand/generate_outreach_drafts_logic.go
+++ /dev/null
@@ -1,96 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- liboutreach "haixun-backend/internal/library/outreach"
- outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GenerateOutreachDraftsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGenerateOutreachDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOutreachDraftsLogic {
- return &GenerateOutreachDraftsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GenerateOutreachDraftsLogic) GenerateOutreachDrafts(req *types.GenerateOutreachDraftsHandlerReq) (resp *types.GenerateOutreachDraftsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scanPostID := strings.TrimSpace(req.ScanPostID)
- if scanPostID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
- }
- if strings.TrimSpace(req.VoicePersonaID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
- }
-
- saved, err := liboutreach.GenerateDraft(l.ctx, outreachDepsFrom(l.svcCtx), liboutreach.GenerateDraftRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- TopicID: strings.TrimSpace(req.TopicID),
- ScanPostID: scanPostID,
- Count: req.Count,
- VoicePersonaID: req.VoicePersonaID,
- ProductID: req.ProductID,
- Regenerate: req.Regenerate,
- })
- if err != nil {
- return nil, err
- }
- return toOutreachDraftData(saved), nil
-}
-
-func outreachDepsFrom(svcCtx *svc.ServiceContext) liboutreach.GenerateDraftDeps {
- return liboutreach.GenerateDraftDeps{
- Brand: svcCtx.Brand,
- Persona: svcCtx.Persona,
- ScanPost: svcCtx.ScanPost,
- KnowledgeGraph: svcCtx.KnowledgeGraph,
- ThreadsAccount: svcCtx.ThreadsAccount,
- AI: svcCtx.AI,
- OutreachDraft: svcCtx.OutreachDraft,
- }
-}
-
-func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData {
- if saved == nil {
- return nil
- }
- drafts := make([]types.OutreachDraftItemData, 0, len(saved.Drafts))
- for _, item := range saved.Drafts {
- drafts = append(drafts, types.OutreachDraftItemData{
- Text: item.Text,
- Angle: item.Angle,
- Rationale: item.Rationale,
- })
- }
- return &types.GenerateOutreachDraftsData{
- ID: saved.ID,
- ScanPostID: saved.ScanPostID,
- Relevance: saved.Relevance,
- Reason: saved.Reason,
- Drafts: drafts,
- CreateAt: saved.CreateAt,
- }
-}
diff --git a/old/backend/internal/logic/brand/get_brand_content_matrix_logic.go b/old/backend/internal/logic/brand/get_brand_content_matrix_logic.go
deleted file mode 100644
index aed7434..0000000
--- a/old/backend/internal/logic/brand/get_brand_content_matrix_logic.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- cmatrixusecase "haixun-backend/internal/model/content_matrix/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetBrandContentMatrixLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetBrandContentMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBrandContentMatrixLogic {
- return &GetBrandContentMatrixLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetBrandContentMatrixLogic) GetBrandContentMatrix(req *types.BrandPath) (resp *types.ContentMatrixData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- matrix, err := l.svcCtx.ContentMatrix.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toContentMatrixData(matrix), nil
-}
-
-func toContentMatrixData(matrix *cmatrixusecase.MatrixSummary) *types.ContentMatrixData {
- if matrix == nil {
- return &types.ContentMatrixData{Rows: []types.ContentMatrixRowData{}}
- }
- rows := make([]types.ContentMatrixRowData, 0, len(matrix.Rows))
- for _, row := range matrix.Rows {
- rows = append(rows, types.ContentMatrixRowData{
- SortOrder: row.SortOrder,
- SearchTag: row.SearchTag,
- Angle: row.Angle,
- Hook: row.Hook,
- Text: row.Text,
- ReferenceNotes: row.ReferenceNotes,
- SourcePermalinks: row.SourcePermalinks,
- Rationale: row.Rationale,
- })
- }
- return &types.ContentMatrixData{
- ID: matrix.ID,
- BrandID: matrix.BrandID,
- Rows: rows,
- GeneratedAt: matrix.GeneratedAt,
- CreateAt: matrix.CreateAt,
- UpdateAt: matrix.UpdateAt,
- }
-}
diff --git a/old/backend/internal/logic/brand/get_brand_logic.go b/old/backend/internal/logic/brand/get_brand_logic.go
deleted file mode 100644
index 436b677..0000000
--- a/old/backend/internal/logic/brand/get_brand_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-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.BrandPath) (resp *types.BrandData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- out := toBrandData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/brand/get_brand_scan_schedule_logic.go b/old/backend/internal/logic/brand/get_brand_scan_schedule_logic.go
deleted file mode 100644
index 6be2b3a..0000000
--- a/old/backend/internal/logic/brand/get_brand_scan_schedule_logic.go
+++ /dev/null
@@ -1,84 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- jobentity "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-const placementScanTemplate = "placement-scan"
-
-type GetBrandScanScheduleLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetBrandScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetBrandScanScheduleLogic {
- return &GetBrandScanScheduleLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetBrandScanScheduleLogic) GetBrandScanSchedule(req *types.BrandPath) (resp *types.BrandScanScheduleData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- schedule, err := findBrandPlacementSchedule(l.ctx, l.svcCtx, req.ID)
- if err != nil {
- return nil, err
- }
- if schedule == nil {
- return &types.BrandScanScheduleData{
- BrandID: req.ID,
- Cron: "0 9 * * *",
- Timezone: "Asia/Taipei",
- Enabled: false,
- }, nil
- }
- return toBrandScanScheduleData(schedule, req.ID), nil
-}
-
-func findBrandPlacementSchedule(ctx context.Context, svcCtx *svc.ServiceContext, brandID string) (*jobentity.Schedule, error) {
- schedules, _, _, _, _, err := svcCtx.Job.ListSchedules(ctx, "brand", brandID, 1, 50)
- if err != nil {
- return nil, err
- }
- for _, schedule := range schedules {
- if schedule != nil && schedule.TemplateType == placementScanTemplate {
- return schedule, nil
- }
- }
- return nil, nil
-}
-
-func toBrandScanScheduleData(schedule *jobentity.Schedule, brandID string) *types.BrandScanScheduleData {
- if schedule == nil {
- return nil
- }
- data := &types.BrandScanScheduleData{
- ID: schedule.ID.Hex(),
- BrandID: brandID,
- Cron: schedule.Cron,
- Timezone: schedule.Timezone,
- Enabled: schedule.Enabled,
- NextRunAt: schedule.NextRunAt,
- }
- if schedule.LastRunAt != nil {
- data.LastRunAt = *schedule.LastRunAt
- }
- return data
-}
diff --git a/old/backend/internal/logic/brand/get_knowledge_graph_logic.go b/old/backend/internal/logic/brand/get_knowledge_graph_logic.go
deleted file mode 100644
index b0923c7..0000000
--- a/old/backend/internal/logic/brand/get_knowledge_graph_logic.go
+++ /dev/null
@@ -1,105 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetKnowledgeGraphLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetKnowledgeGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetKnowledgeGraphLogic {
- return &GetKnowledgeGraphLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetKnowledgeGraphLogic) GetKnowledgeGraph(req *types.BrandPath) (resp *types.KnowledgeGraphData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- graph, err := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- data := toKnowledgeGraphData(graph)
- return &data, nil
-}
-
-func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphData {
- if graph == nil {
- return types.KnowledgeGraphData{}
- }
- nodes := make([]types.KnowledgeGraphNodeData, 0, len(graph.Nodes))
- for _, node := range graph.Nodes {
- evidence := make([]types.KnowledgeGraphEvidenceData, 0, len(node.Evidence))
- for _, item := range node.Evidence {
- evidence = append(evidence, types.KnowledgeGraphEvidenceData{
- URL: item.URL,
- Snippet: item.Snippet,
- Query: item.Query,
- })
- }
- nodes = append(nodes, types.KnowledgeGraphNodeData{
- ID: node.ID,
- Label: node.Label,
- NodeKind: node.NodeKind,
- Type: node.Type,
- Layer: node.Layer,
- Relation: node.Relation,
- PlacementValue: node.PlacementValue,
- ProductFitScore: node.ProductFitScore,
- SelectedForScan: node.SelectedForScan,
- RelevanceTags: append([]string{}, node.DerivedTags.Relevance...),
- RecencyTags: append([]string{}, node.DerivedTags.Recency...),
- Evidence: evidence,
- })
- }
- edges := make([]types.KnowledgeGraphEdgeData, 0, len(graph.Edges))
- for _, edge := range graph.Edges {
- edges = append(edges, types.KnowledgeGraphEdgeData{
- From: edge.From,
- To: edge.To,
- Relation: edge.Relation,
- })
- }
- sources := make([]types.BraveSourceData, 0, len(graph.BraveSources))
- for _, src := range graph.BraveSources {
- sources = append(sources, types.BraveSourceData{
- Query: src.Query,
- Title: src.Title,
- URL: src.URL,
- Snippet: src.Snippet,
- })
- }
- return types.KnowledgeGraphData{
- ID: graph.ID,
- BrandID: graph.BrandID,
- Seed: graph.Seed,
- Nodes: nodes,
- Edges: edges,
- BraveSources: sources,
- ExpandStrategy: graph.ExpandStrategy,
- PainTagCount: graph.PainTagCount,
- GeneratedAt: graph.GeneratedAt,
- CreateAt: graph.CreateAt,
- UpdateAt: graph.UpdateAt,
- }
-}
diff --git a/old/backend/internal/logic/brand/list_brand_products_logic.go b/old/backend/internal/logic/brand/list_brand_products_logic.go
deleted file mode 100644
index 3ecb8e3..0000000
--- a/old/backend/internal/logic/brand/list_brand_products_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListBrandProductsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListBrandProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListBrandProductsLogic {
- return &ListBrandProductsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListBrandProductsLogic) ListBrandProducts(req *types.BrandPath) (resp *types.ListBrandProductsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.Brand.ListProducts(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toListBrandProductsData(result), nil
-}
diff --git a/old/backend/internal/logic/brand/list_brand_scan_posts_logic.go b/old/backend/internal/logic/brand/list_brand_scan_posts_logic.go
deleted file mode 100644
index 1f4b884..0000000
--- a/old/backend/internal/logic/brand/list_brand_scan_posts_logic.go
+++ /dev/null
@@ -1,70 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListBrandScanPostsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListBrandScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListBrandScanPostsLogic {
- return &ListBrandScanPostsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListBrandScanPostsLogic) ListBrandScanPosts(req *types.ListBrandScanPostsHandlerReq) (resp *types.ListBrandScanPostsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
-
- listReq := scanpostusecase.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- Limit: 100,
- }
- listReq.Priority = strings.TrimSpace(req.Priority)
- listReq.Recent7dOnly = req.Recent7d
- listReq.ProductFitMin = req.ProductFitMin
- if req.Limit > 0 {
- listReq.Limit = req.Limit
- }
-
- posts, err := l.svcCtx.ScanPost.List(l.ctx, listReq)
- if err != nil {
- return nil, err
- }
-
- list := make([]types.ScanPostData, 0, len(posts))
- for _, post := range posts {
- if mapped := toScanPostData(&post); mapped != nil {
- if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, req.ID, "", post.ID); draftErr != nil {
- return nil, draftErr
- } else if draft != nil {
- mapped.LatestDraft = toOutreachDraftData(draft)
- }
- list = append(list, *mapped)
- }
- }
- return &types.ListBrandScanPostsData{List: list, Total: len(list)}, nil
-}
diff --git a/old/backend/internal/logic/brand/list_brands_logic.go b/old/backend/internal/logic/brand/list_brands_logic.go
deleted file mode 100644
index c68661f..0000000
--- a/old/backend/internal/logic/brand/list_brands_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-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() (resp *types.ListBrandsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.Brand.List(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- return toListData(result), nil
-}
diff --git a/old/backend/internal/logic/brand/mapper.go b/old/backend/internal/logic/brand/mapper.go
deleted file mode 100644
index bd18f9c..0000000
--- a/old/backend/internal/logic/brand/mapper.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package brand
-
-import (
- brandentity "haixun-backend/internal/model/brand/domain/entity"
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toBrandData(item domusecase.BrandSummary) types.BrandData {
- products := make([]types.BrandProductData, 0, len(item.Products))
- for _, product := range item.Products {
- products = append(products, toBrandProductData(product))
- }
- return types.BrandData{
- ID: item.ID,
- DisplayName: item.DisplayName,
- TopicName: item.TopicName,
- SeedQuery: item.SeedQuery,
- Brief: item.Brief,
- ProductBrief: item.ProductBrief,
- ProductContext: item.ProductContext,
- ProductID: item.ProductID,
- Products: products,
- TargetAudience: item.TargetAudience,
- Goals: item.Goals,
- ResearchMap: toResearchMapData(item.ResearchMap),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toBrandProductData(item domusecase.ProductSummary) types.BrandProductData {
- return types.BrandProductData{
- ID: item.ID,
- Label: item.Label,
- ProductContext: item.ProductContext,
- PlacementURL: item.PlacementURL,
- MatchTags: append([]string(nil), item.MatchTags...),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toListBrandProductsData(result *domusecase.ListProductsResult) *types.ListBrandProductsData {
- if result == nil {
- return &types.ListBrandProductsData{List: []types.BrandProductData{}}
- }
- list := make([]types.BrandProductData, 0, len(result.List))
- for _, item := range result.List {
- list = append(list, toBrandProductData(item))
- }
- return &types.ListBrandProductsData{List: list}
-}
-
-func toResearchMapData(item brandentity.ResearchMap) types.ResearchMapData {
- items := make([]types.ResearchItemData, 0, len(item.ResearchItems))
- for _, researchItem := range item.ResearchItems {
- items = append(items, types.ResearchItemData{
- Title: researchItem.Title,
- URL: researchItem.URL,
- Snippet: researchItem.Snippet,
- Query: researchItem.Query,
- })
- }
- return types.ResearchMapData{
- AudienceSummary: item.AudienceSummary,
- ContentGoal: item.ContentGoal,
- Questions: item.Questions,
- Pillars: item.Pillars,
- Exclusions: item.Exclusions,
- ResearchItems: items,
- ExpandStrategy: item.ExpandStrategy,
- PatrolKeywords: append([]string(nil), item.PatrolKeywords...),
- }
-}
-
-func toListData(result *domusecase.ListResult) *types.ListBrandsData {
- if result == nil {
- return &types.ListBrandsData{List: []types.BrandData{}}
- }
- list := make([]types.BrandData, 0, len(result.List))
- for _, item := range result.List {
- list = append(list, toBrandData(item))
- }
- return &types.ListBrandsData{List: list}
-}
-
-func toBrandPatch(req *types.UpdateBrandReq) domusecase.BrandPatch {
- if req == nil {
- return domusecase.BrandPatch{}
- }
- patch := domusecase.BrandPatch{
- DisplayName: req.DisplayName,
- TopicName: req.TopicName,
- SeedQuery: req.SeedQuery,
- Brief: req.Brief,
- ProductBrief: req.ProductBrief,
- ProductContext: req.ProductContext,
- ProductID: req.ProductID,
- TargetAudience: req.TargetAudience,
- Goals: req.Goals,
- }
- if req.AudienceSummary != nil {
- patch.AudienceSummary = req.AudienceSummary
- }
- if req.ContentGoal != nil {
- patch.ContentGoal = req.ContentGoal
- }
- if req.Questions != nil {
- patch.QuestionsSet = true
- patch.Questions = append([]string(nil), req.Questions...)
- }
- if req.Pillars != nil {
- patch.PillarsSet = true
- patch.Pillars = append([]string(nil), req.Pillars...)
- }
- if req.Exclusions != nil {
- patch.ExclusionsSet = true
- patch.Exclusions = append([]string(nil), req.Exclusions...)
- }
- if req.PatrolKeywords != nil {
- patch.PatrolKeywordsSet = true
- patch.PatrolKeywords = append([]string(nil), req.PatrolKeywords...)
- }
- return patch
-}
diff --git a/old/backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go b/old/backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go
deleted file mode 100644
index c7aba8e..0000000
--- a/old/backend/internal/logic/brand/patch_knowledge_graph_nodes_logic.go
+++ /dev/null
@@ -1,83 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PatchKnowledgeGraphNodesLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPatchKnowledgeGraphNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchKnowledgeGraphNodesLogic {
- return &PatchKnowledgeGraphNodesLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *PatchKnowledgeGraphNodesLogic) PatchKnowledgeGraphNodes(req *types.PatchKnowledgeGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if len(req.Updates) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("updates is required")
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
-
- updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates))
- for _, item := range req.Updates {
- nodeID := strings.TrimSpace(item.NodeID)
- if nodeID == "" {
- continue
- }
- update := kgusecase.NodeUpdate{NodeID: nodeID}
- if item.SelectedForScan != nil {
- update.SelectedForScan = item.SelectedForScan
- }
- if item.RelevanceTags != nil {
- update.RelevanceTagsSet = true
- update.RelevanceTags = append([]string(nil), item.RelevanceTags...)
- }
- if item.RecencyTags != nil {
- update.RecencyTagsSet = true
- update.RecencyTags = append([]string(nil), item.RecencyTags...)
- }
- if update.SelectedForScan == nil && !update.RelevanceTagsSet && !update.RecencyTagsSet {
- return nil, app.For(code.Brand).InputMissingRequired("each update needs selected_for_scan or patrol tags")
- }
- updates = append(updates, update)
- }
- if len(updates) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("updates is required")
- }
-
- graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- Updates: updates,
- })
- if err != nil {
- return nil, err
- }
- data := toKnowledgeGraphData(graph)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/brand/patch_scan_post_outreach_logic.go b/old/backend/internal/logic/brand/patch_scan_post_outreach_logic.go
deleted file mode 100644
index cc9e067..0000000
--- a/old/backend/internal/logic/brand/patch_scan_post_outreach_logic.go
+++ /dev/null
@@ -1,113 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PatchScanPostOutreachLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPatchScanPostOutreachLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchScanPostOutreachLogic {
- return &PatchScanPostOutreachLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *PatchScanPostOutreachLogic) PatchScanPostOutreach(req *types.PatchScanPostOutreachHandlerReq) (resp *types.ScanPostData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- status := ""
- if req.PatchScanPostOutreachReq.OutreachStatus != nil {
- status = strings.TrimSpace(*req.PatchScanPostOutreachReq.OutreachStatus)
- }
- if status == "" {
- return nil, app.For(code.Brand).InputMissingRequired("outreach_status is required")
- }
- switch status {
- case scanpostentity.OutreachStatusHandled, scanpostentity.OutreachStatusSkipped, scanpostentity.OutreachStatusPending:
- default:
- return nil, app.For(code.Brand).InputInvalidFormat("outreach_status must be handled, skipped, or pending")
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.BrandPath.ID); err != nil {
- return nil, err
- }
- updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, scanpostusecase.UpdateOutreachRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.BrandPath.ID,
- PostID: req.PostID,
- Status: status,
- })
- if err != nil {
- return nil, err
- }
- return toScanPostData(updated), nil
-}
-
-func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData {
- if post == nil {
- return nil
- }
- return &types.ScanPostData{
- ID: post.ID,
- GraphNodeID: post.GraphNodeID,
- SearchTag: post.SearchTag,
- QueryDimension: post.QueryDimension,
- RecencyDays: post.RecencyDays,
- ExternalID: post.ExternalID,
- Permalink: post.Permalink,
- Author: post.Author,
- Text: post.Text,
- Priority: post.Priority,
- PlacementScore: post.PlacementScore,
- ProductFitScore: post.ProductFitScore,
- SolvedByProduct: post.SolvedByProduct,
- Source: post.Source,
- ScanJobID: post.ScanJobID,
- OutreachStatus: post.OutreachStatus,
- PublishedReplyID: post.PublishedReplyID,
- PublishedPermalink: post.PublishedPermalink,
- OutreachUpdateAt: post.OutreachUpdateAt,
- PostedAt: post.PostedAt,
- Replies: toScanReplyData(post.Replies),
- CreateAt: post.CreateAt,
- }
-}
-
-func toScanReplyData(replies []scanpostusecase.ScanReplySummary) []types.ScanReplyData {
- if len(replies) == 0 {
- return nil
- }
- out := make([]types.ScanReplyData, 0, len(replies))
- for _, reply := range replies {
- out = append(out, types.ScanReplyData{
- ExternalID: reply.ExternalID,
- Author: reply.Author,
- Text: reply.Text,
- Permalink: reply.Permalink,
- LikeCount: reply.LikeCount,
- PostedAt: reply.PostedAt,
- })
- }
- return out
-}
diff --git a/old/backend/internal/logic/brand/publish_outreach_draft_logic.go b/old/backend/internal/logic/brand/publish_outreach_draft_logic.go
deleted file mode 100644
index 2a2d965..0000000
--- a/old/backend/internal/logic/brand/publish_outreach_draft_logic.go
+++ /dev/null
@@ -1,133 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- liboutreach "haixun-backend/internal/library/outreach"
- libplacement "haixun-backend/internal/library/placement"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/threadspost"
- scanpostentity "haixun-backend/internal/model/scan_post/domain/entity"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PublishOutreachDraftLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPublishOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishOutreachDraftLogic {
- return &PublishOutreachDraftLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *PublishOutreachDraftLogic) PublishOutreachDraft(req *types.PublishOutreachDraftHandlerReq) (resp *types.PublishOutreachDraftData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scanPostID := strings.TrimSpace(req.ScanPostID)
- text := liboutreach.NormalizeDraftText(req.Text)
- confirm := req.Confirm
- if scanPostID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("scan_post_id is required")
- }
- if !confirm {
- return nil, app.For(code.Brand).InputMissingRequired("請確認 confirm=true 後再發送留言")
- }
- if text == "" {
- return nil, app.For(code.Brand).InputMissingRequired("text is required")
- }
- if err := threadspost.ValidateReply(text); err != nil {
- return nil, app.For(code.Brand).InputInvalidFormat(err.Error())
- }
-
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- post, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, req.ID, scanPostID)
- if err != nil {
- return nil, err
- }
- rawTargetID := libplacement.ResolveReplyTargetID(post.ExternalID, post.Permalink)
- if rawTargetID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("此貼文缺少 Threads 貼文 ID(external_id 或有效 permalink),無法透過 API 回覆")
- }
-
- cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsReplyCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- storageState := ""
- if session, sessErr := l.svcCtx.ThreadsAccount.GetBrowserSession(l.ctx, tenantID, uid, cred.AccountID); sessErr == nil && session != nil {
- storageState = strings.TrimSpace(session.StorageState)
- }
- resolveInput := libplacement.ReplyMediaResolveInput{
- AccessToken: cred.AccessToken,
- StorageState: storageState,
- ExternalID: rawTargetID,
- Permalink: post.Permalink,
- HintText: post.Text,
- }
- replyToID, err := libplacement.ResolveReplyMediaForPublish(l.ctx, resolveInput)
- if err != nil {
- return nil, app.For(code.Brand).InputMissingRequired(err.Error())
- }
- apiClient := libthreads.NewClient(cred.AccessToken)
- if err := apiClient.PrimeReplyTargetForPublish(l.ctx, libthreads.ResolveReplyMediaInput{
- ExternalID: rawTargetID,
- Permalink: post.Permalink,
- HintText: post.Text,
- }, replyToID); err != nil {
- return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 無法回覆此貼文:" + err.Error())
- }
- result, err := libthreads.PublishReply(l.ctx, libthreads.PublishReplyInput{
- ThreadsUserID: cred.ThreadsUserID,
- AccessToken: cred.AccessToken,
- ReplyToID: replyToID,
- Text: text,
- })
- if err != nil {
- return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發送留言失敗:" + err.Error())
- }
-
- patch := scanpostusecase.UpdateOutreachRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- PostID: scanPostID,
- Status: scanpostentity.OutreachStatusPublished,
- PublishedReplyID: result.MediaID,
- PublishedPermalink: result.Permalink,
- }
- if libthreads.IsNumericMediaID(replyToID) && !libthreads.IsNumericMediaID(post.ExternalID) {
- patch.ExternalID = replyToID
- }
- updated, err := l.svcCtx.ScanPost.UpdateOutreach(l.ctx, patch)
- if err != nil {
- return nil, err
- }
-
- return &types.PublishOutreachDraftData{
- ScanPostID: scanPostID,
- ReplyID: result.MediaID,
- Permalink: result.Permalink,
- OutreachStatus: updated.OutreachStatus,
- PublishedPermalink: updated.PublishedPermalink,
- Message: "獲客留言已透過 Threads API 發送",
- }, nil
-}
diff --git a/old/backend/internal/logic/brand/start_brand_scan_job_logic.go b/old/backend/internal/logic/brand/start_brand_scan_job_logic.go
deleted file mode 100644
index 21ba896..0000000
--- a/old/backend/internal/logic/brand/start_brand_scan_job_logic.go
+++ /dev/null
@@ -1,159 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- kgdom "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-func IsKnowledgeGraphNotFound(err error) bool {
- if err == nil {
- return false
- }
- if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound {
- return true
- }
- return strings.Contains(strings.ToLower(err.Error()), "knowledge graph not found")
-}
-
-type StartBrandScanJobLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartBrandScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartBrandScanJobLogic {
- return &StartBrandScanJobLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *StartBrandScanJobLogic) StartBrandScanJob(req *types.StartBrandScanJobHandlerReq) (resp *types.StartBrandScanJobData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- brand, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- var graph *kgdom.GraphSummary
- if loaded, err := l.svcCtx.KnowledgeGraph.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- if !IsKnowledgeGraphNotFound(err) {
- return nil, err
- }
- } else {
- graph = loaded
- }
-
- selected := 0
- if graph != nil {
- for _, node := range graph.Nodes {
- if node.SelectedForScan {
- selected++
- }
- }
- }
- nodeIDs := []string{}
- for _, id := range req.NodeIDs {
- id = strings.TrimSpace(id)
- if id != "" {
- nodeIDs = append(nodeIDs, id)
- }
- }
- graphID := req.ID
- if graph != nil && strings.TrimSpace(graph.ID) != "" {
- graphID = graph.ID
- }
- if strings.TrimSpace(req.GraphID) != "" {
- graphID = strings.TrimSpace(req.GraphID)
- }
- dualTrack := true
- patrolMode := req.PatrolMode
- productBrief := strings.TrimSpace(brand.ProductBrief)
- if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
- productBrief = formatted
- }
- patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
- patrolNodes := []libkg.Node{}
- if graph != nil {
- patrolNodes = graph.Nodes
- }
- patrolKeywords := libkg.ResolveScanPatrolKeywords(
- req.PatrolKeywords,
- brand.ResearchMap.PatrolKeywords,
- patrolInput,
- patrolNodes,
- )
- if patrolMode || (len(nodeIDs) == 0 && selected == 0) {
- if len(patrolKeywords) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("請先產生研究地圖,系統會依研究地圖自動整理海巡關鍵字")
- }
- patrolMode = true
- } else if graph == nil {
- return nil, app.For(code.Brand).ResNotFound("請先產生延伸知識圖譜,或改用手動海巡關鍵字")
- }
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
- if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
- return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session")
- }
- if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) {
- return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research))
- }
- if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
- return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡")
- }
-
- payload := map[string]any{
- "brand_id": req.ID,
- "graph_id": graphID,
- "dual_track": dualTrack,
- "node_ids": nodeIDs,
- }
- if patrolMode {
- payload["patrol_mode"] = true
- payload["patrol_keywords"] = patrolKeywords
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "placement-scan",
- Scope: "brand",
- ScopeID: req.ID,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
-
- return &types.StartBrandScanJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "雙軌海巡已在背景執行,完成後可到獲客台查看貼文",
- }, nil
-}
diff --git a/old/backend/internal/logic/brand/update_brand_logic.go b/old/backend/internal/logic/brand/update_brand_logic.go
deleted file mode 100644
index c27a7b6..0000000
--- a/old/backend/internal/logic/brand/update_brand_logic.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateBrandLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateBrandLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateBrandLogic {
- return &UpdateBrandLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateBrandLogic) UpdateBrand(req *types.UpdateBrandHandlerReq) (resp *types.BrandData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Brand.Update(l.ctx, domusecase.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- Patch: toBrandPatch(&req.UpdateBrandReq),
- })
- if err != nil {
- return nil, err
- }
- out := toBrandData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/brand/update_brand_product_logic.go b/old/backend/internal/logic/brand/update_brand_product_logic.go
deleted file mode 100644
index 185518e..0000000
--- a/old/backend/internal/logic/brand/update_brand_product_logic.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateBrandProductLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateBrandProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateBrandProductLogic {
- return &UpdateBrandProductLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateBrandProductLogic) UpdateBrandProduct(req *types.UpdateBrandProductHandlerReq) (resp *types.BrandProductData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- placementURL := strings.TrimSpace(req.PlacementURL)
- item, err := l.svcCtx.Brand.UpdateProduct(l.ctx, domusecase.UpdateProductRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- ProductID: req.ProductID,
- Label: req.Label,
- ProductContext: req.ProductContext,
- PlacementURL: &placementURL,
- PlacementURLSet: true,
- MatchTags: req.MatchTags,
- MatchTagsSet: req.MatchTags != nil,
- })
- if err != nil {
- return nil, err
- }
- out := toBrandProductData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/brand/update_outreach_draft_logic.go b/old/backend/internal/logic/brand/update_outreach_draft_logic.go
deleted file mode 100644
index dc95ca5..0000000
--- a/old/backend/internal/logic/brand/update_outreach_draft_logic.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package brand
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- liboutreach "haixun-backend/internal/library/outreach"
- "haixun-backend/internal/library/threadspost"
- outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateOutreachDraftLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateOutreachDraftLogic {
- return &UpdateOutreachDraftLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateOutreachDraftLogic) UpdateOutreachDraft(req *types.UpdateOutreachDraftHandlerReq) (*types.GenerateOutreachDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- draftID := strings.TrimSpace(req.DraftID)
- if draftID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("draft_id is required")
- }
- text := liboutreach.NormalizeDraftText(req.Text)
- if text == "" {
- return nil, app.For(code.Brand).InputMissingRequired("text is required")
- }
- if err := threadspost.ValidateReply(text); err != nil {
- return nil, app.For(code.Brand).InputInvalidFormat(err.Error())
- }
- if req.DraftIndex < 0 {
- return nil, app.For(code.Brand).InputInvalidFormat("draft_index must be >= 0")
- }
-
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
-
- updated, err := l.svcCtx.OutreachDraft.UpdateDraftItem(l.ctx, outreachusecase.UpdateDraftItemRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: req.ID,
- DraftID: draftID,
- DraftIndex: req.DraftIndex,
- Text: text,
- })
- if err != nil {
- return nil, err
- }
- return toOutreachDraftData(updated), nil
-}
diff --git a/old/backend/internal/logic/brand/upsert_brand_scan_schedule_logic.go b/old/backend/internal/logic/brand/upsert_brand_scan_schedule_logic.go
deleted file mode 100644
index 2090f81..0000000
--- a/old/backend/internal/logic/brand/upsert_brand_scan_schedule_logic.go
+++ /dev/null
@@ -1,100 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package brand
-
-import (
- "context"
- "strings"
-
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpsertBrandScanScheduleLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertBrandScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertBrandScanScheduleLogic {
- return &UpsertBrandScanScheduleLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpsertBrandScanScheduleLogic) UpsertBrandScanSchedule(req *types.UpsertBrandScanScheduleHandlerReq) (resp *types.BrandScanScheduleData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Brand.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
-
- cronExpr := "0 9 * * *"
- timezone := "Asia/Taipei"
- enabled := false
- if strings.TrimSpace(req.Cron) != "" {
- cronExpr = strings.TrimSpace(req.Cron)
- }
- if strings.TrimSpace(req.Timezone) != "" {
- timezone = strings.TrimSpace(req.Timezone)
- }
- enabled = req.Enabled
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
-
- payload := map[string]any{
- "brand_id": req.ID,
- "dual_track": true,
- "scheduled": true,
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- existing, err := findBrandPlacementSchedule(l.ctx, l.svcCtx, req.ID)
- if err != nil {
- return nil, err
- }
- if existing != nil {
- updated, err := l.svcCtx.Job.UpdateSchedule(l.ctx, jobdom.UpdateScheduleRequest{
- ID: existing.ID.Hex(),
- Cron: cronExpr,
- Timezone: timezone,
- PayloadTemplate: payload,
- Enabled: &enabled,
- })
- if err != nil {
- return nil, err
- }
- return toBrandScanScheduleData(updated, req.ID), nil
- }
-
- created, err := l.svcCtx.Job.CreateSchedule(l.ctx, jobdom.CreateScheduleRequest{
- TemplateType: placementScanTemplate,
- Scope: "brand",
- ScopeID: req.ID,
- Cron: cronExpr,
- Timezone: timezone,
- PayloadTemplate: payload,
- Enabled: enabled,
- })
- if err != nil {
- return nil, err
- }
- return toBrandScanScheduleData(created, req.ID), nil
-}
diff --git a/old/backend/internal/logic/copy_mission/actor.go b/old/backend/internal/logic/copy_mission/actor.go
deleted file mode 100644
index 882a6f6..0000000
--- a/old/backend/internal/logic/copy_mission/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package copy_mission
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/old/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go b/old/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go
deleted file mode 100644
index 98fe6ea..0000000
--- a/old/backend/internal/logic/copy_mission/inspire_copy_mission_logic.go
+++ /dev/null
@@ -1,373 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package copy_mission
-
-import (
- "context"
- "strings"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/style8d"
- libviral "haixun-backend/internal/library/viral"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type InspireCopyMissionLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewInspireCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InspireCopyMissionLogic {
- return &InspireCopyMissionLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *InspireCopyMissionLogic) InspireCopyMission(req *types.CopyMissionInspirationHandlerReq) (resp *types.CopyMissionInspirationData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.PersonaID)
- if personaID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("persona_id is required")
- }
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- keyword := strings.TrimSpace(req.Keyword)
- trends, sources, searchUsed := []libviral.MissionInspireTrendSnippet(nil), []types.CopyMissionInspirationSourceData(nil), false
- if req.UseWebSearch {
- trends, sources, searchUsed = l.collectTrendSignals(tenantID, uid, persona, keyword)
- }
- llmOnly := len(trends) == 0
- if keyword != "" && llmOnly {
- trends = append(trends, libviral.MissionInspireTrendSnippet{
- Query: keyword,
- Title: "使用者指定方向",
- Snippet: keyword,
- })
- }
-
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
- temp := 0.72
- result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{APIKey: credential.APIKey},
- System: libviral.BuildMissionInspireSystemPrompt(),
- Temperature: &temp,
- Messages: []domai.Message{{Role: "user", Content: libviral.BuildMissionInspireUserPrompt(libviral.MissionInspireInput{
- PersonaDisplayName: persona.DisplayName,
- PersonaBrief: persona.Brief,
- PersonaBlock: personaBlock,
- StyleBenchmark: persona.StyleBenchmark,
- AvoidTopics: cleanAvoidTopics(req.AvoidTopics),
- ContentDirection: req.ContentDirection,
- UserKeyword: keyword,
- TrendSnippets: trends,
- WebSearchProvider: sourceLabel(searchUsed, sources),
- LLMOnly: llmOnly,
- })}},
- })
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("起號靈感生成失敗:" + err.Error())
- }
- parsed, err := libviral.ParseMissionInspireOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("起號靈感回傳無法解析:" + err.Error())
- }
-
- topicCandidate, plan := l.persistInspiration(tenantID, uid, personaID, parsed, searchUsed)
- return &types.CopyMissionInspirationData{
- TopicCandidateID: topicCandidate,
- ContentPlanID: plan,
- Label: parsed.Label,
- SeedQuery: parsed.SeedQuery,
- Brief: parsed.Brief,
- TrendReason: parsed.TrendReason,
- TrendKeywords: parsed.TrendKeywords,
- Angles: parsed.Angles,
- Mission: parsed.Mission,
- TargetAudience: parsed.TargetAudience,
- OpeningType: parsed.OpeningType,
- BodyType: parsed.BodyType,
- Emotion: parsed.Emotion,
- CtaType: parsed.CtaType,
- RiskLevel: parsed.RiskLevel,
- Avoid: parsed.Avoid,
- Sources: sources,
- WebSearchUsed: searchUsed,
- Message: "已找到一個可延伸的話題與內容計畫",
- }, nil
-}
-
-func (l *InspireCopyMissionLogic) persistInspiration(tenantID, uid, personaID string, parsed libviral.MissionInspireOutput, searchUsed bool) (string, string) {
- if l.svcCtx.ContentOps == nil {
- return "", ""
- }
- riskScore := riskScoreFromLevel(parsed.RiskLevel)
- category := parsed.Mission
- if category == "" {
- category = "候選話題"
- }
- source := "llm"
- if searchUsed {
- source = "topic_radar"
- }
- topic, err := l.svcCtx.ContentOps.CreateTopicCandidate(l.ctx, contentops.TopicCandidateInput{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- Name: parsed.Label,
- Source: source,
- Category: category,
- SeedQuery: parsed.SeedQuery,
- TrendReason: parsed.TrendReason,
- TrendKeywords: parsed.TrendKeywords,
- HeatScore: scoreBySearch(searchUsed),
- FitScore: 82,
- InteractionScore: 70,
- ExtendScore: 78,
- RiskScore: riskScore,
- RecommendedMissions: []string{parsed.Mission},
- })
- if err != nil {
- l.Errorf("persist topic candidate: %v", err)
- return "", ""
- }
- angle := ""
- if len(parsed.Angles) > 0 {
- angle = parsed.Angles[0]
- }
- plan, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- TopicCandidateID: topic.ID,
- Topic: parsed.Label,
- Mission: fallbackString(parsed.Mission, "互動文"),
- TargetAudience: parsed.TargetAudience,
- Angle: angle,
- OpeningType: parsed.OpeningType,
- BodyType: parsed.BodyType,
- Emotion: parsed.Emotion,
- CtaType: parsed.CtaType,
- RiskLevel: parsed.RiskLevel,
- RequiresHumanReview: parsed.RiskLevel != "low",
- Avoid: parsed.Avoid,
- })
- if err != nil {
- l.Errorf("persist content plan: %v", err)
- return topic.ID, ""
- }
- return topic.ID, plan.ID
-}
-
-func scoreBySearch(searchUsed bool) int {
- if searchUsed {
- return 76
- }
- return 55
-}
-
-func riskScoreFromLevel(level string) int {
- switch strings.ToLower(strings.TrimSpace(level)) {
- case "high":
- return 75
- case "low":
- return 20
- default:
- return 45
- }
-}
-
-func fallbackString(value, fallback string) string {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- return fallback
-}
-
-func (l *InspireCopyMissionLogic) collectTrendSignals(tenantID, uid string, persona *personadomain.PersonaSummary, keyword string) ([]libviral.MissionInspireTrendSnippet, []types.CopyMissionInspirationSourceData, bool) {
- if persona == nil {
- return nil, nil, false
- }
- trends := make([]libviral.MissionInspireTrendSnippet, 0, 12)
- seen := map[string]struct{}{}
- addTrend := func(item libviral.MissionInspireTrendSnippet) {
- item.Query = strings.TrimSpace(item.Query)
- item.Title = strings.TrimSpace(item.Title)
- item.Snippet = strings.TrimSpace(item.Snippet)
- item.URL = strings.TrimSpace(item.URL)
- if item.Title == "" && item.Snippet == "" {
- return
- }
- if isNewsLikeInspiration(item.Title, item.Snippet, item.URL) {
- return
- }
- key := strings.ToLower(item.Query + "|" + item.Title + "|" + item.Snippet)
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- trends = append(trends, item)
- }
-
- for _, item := range libviral.CollectFreeTopicTrends(l.ctx, keyword, persona.Brief) {
- addTrend(item)
- }
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- sources := inspirationSourcesFromTrends(trends)
- return trends, sources, len(sources) > 0
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- sources := inspirationSourcesFromTrends(trends)
- return trends, sources, len(sources) > 0
- }
-
- if placement.WebSearchAvailable(research) {
- client := websearch.New(memberCtx.WebSearchConfig())
- for _, item := range libviral.CollectMissionInspireTrends(l.ctx, client, memberCtx, strings.TrimSpace(persona.Brief+" "+keyword), persona.StyleBenchmark) {
- addTrend(item)
- }
- if keyword != "" && client.Enabled() {
- for _, query := range []string{
- "Threads 台灣 " + keyword + " 熱門 關鍵字 討論",
- keyword + " 請問 心得 推薦 Threads",
- keyword + " 痛點 經驗 怎麼辦 社群",
- keyword + " Dcard PTT Threads 熱門 討論",
- } {
- res, searchErr := client.Search(l.ctx, websearch.SearchOptions{
- Query: query,
- Limit: 4,
- Mode: websearch.ModeKnowledgeExpand,
- Country: memberCtx.BraveCountry,
- SearchLang: memberCtx.BraveSearchLang,
- UserLocation: memberCtx.ExaUserLocation,
- StartPublishedDate: placement.FormatPublishedAfterISO(14, time.Now().UTC()),
- })
- if searchErr != nil || res.Status != "success" {
- continue
- }
- for _, result := range res.Results {
- addTrend(libviral.MissionInspireTrendSnippet{
- Query: query,
- Title: result.Title,
- Snippet: result.Snippet,
- URL: result.URL,
- })
- }
- }
- }
- }
-
- if keyword != "" && len(trends) < 8 {
- for _, item := range libviral.CollectMissionInspireThreadsTrends(l.ctx, libviral.MissionInspireThreadsInput{
- Keyword: keyword,
- PersonaBrief: persona.Brief,
- StyleBenchmark: persona.StyleBenchmark,
- Pillars: persona.CopyResearchMap.Pillars,
- Questions: persona.CopyResearchMap.Questions,
- Member: memberCtx,
- }) {
- addTrend(item)
- }
- }
-
- if len(trends) > 10 {
- trends = trends[:10]
- }
- sources := inspirationSourcesFromTrends(trends)
- return trends, sources, len(sources) > 0
-}
-
-func isNewsLikeInspiration(title, snippet, url string) bool {
- text := strings.ToLower(title + " " + snippet + " " + url)
- newsSignals := []string{"新聞", "快訊", "中央社", "聯合新聞", "ettoday", "tvbs", "setn", "ctwant", "chinatimes", "ltn.com", "news"}
- socialSignals := []string{"threads", "dcard", "ptt", "請問", "心得", "推薦", "有人", "怎麼辦", "討論", "留言"}
- newsScore := 0
- for _, signal := range newsSignals {
- if strings.Contains(text, strings.ToLower(signal)) {
- newsScore++
- }
- }
- if newsScore == 0 {
- return false
- }
- for _, signal := range socialSignals {
- if strings.Contains(text, strings.ToLower(signal)) {
- return false
- }
- }
- return true
-}
-
-func inspirationSourcesFromTrends(trends []libviral.MissionInspireTrendSnippet) []types.CopyMissionInspirationSourceData {
- sources := make([]types.CopyMissionInspirationSourceData, 0, len(trends))
- for _, item := range trends {
- sources = append(sources, types.CopyMissionInspirationSourceData{
- Query: item.Query,
- Title: item.Title,
- Snippet: item.Snippet,
- URL: item.URL,
- })
- }
- return sources
-}
-
-func sourceLabel(used bool, sources []types.CopyMissionInspirationSourceData) string {
- if !used || len(sources) == 0 {
- return ""
- }
- return "Free Trend / Web / Threads Search"
-}
-
-func cleanAvoidTopics(items []string) []string {
- out := make([]string, 0, len(items))
- seen := map[string]struct{}{}
- for _, item := range items {
- item = strings.TrimSpace(item)
- if item == "" {
- continue
- }
- key := strings.ToLower(item)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, item)
- if len(out) >= 8 {
- break
- }
- }
- return out
-}
diff --git a/old/backend/internal/logic/job/ack_worker_job_cancel_logic.go b/old/backend/internal/logic/job/ack_worker_job_cancel_logic.go
deleted file mode 100644
index c3a8267..0000000
--- a/old/backend/internal/logic/job/ack_worker_job_cancel_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- jobusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type AckWorkerJobCancelLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewAckWorkerJobCancelLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AckWorkerJobCancelLogic {
- return &AckWorkerJobCancelLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *AckWorkerJobCancelLogic) AckWorkerJobCancel(req *types.WorkerJobReq) (resp *types.JobData, err error) {
- run, err := l.svcCtx.Job.AcknowledgeCancel(l.ctx, jobusecase.AcknowledgeCancelRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- })
- if err != nil {
- return nil, err
- }
- data := ToJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/actor.go b/old/backend/internal/logic/job/actor.go
deleted file mode 100644
index 8c04841..0000000
--- a/old/backend/internal/logic/job/actor.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/logic/authz"
- "haixun-backend/internal/svc"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
-
-func requireAdmin(ctx context.Context, svcCtx *svc.ServiceContext) error {
- return authz.RequireAdmin(ctx, svcCtx)
-}
diff --git a/old/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go b/old/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go
deleted file mode 100644
index c51bf43..0000000
--- a/old/backend/internal/logic/job/analyze_style8_d_from_worker_logic.go
+++ /dev/null
@@ -1,181 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/style8d"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- jobenum "haixun-backend/internal/model/job/domain/enum"
- jobusecase "haixun-backend/internal/model/job/domain/usecase"
- personausecase "haixun-backend/internal/model/persona/domain/usecase"
- tausecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type AnalyzeStyle8DFromWorkerLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewAnalyzeStyle8DFromWorkerLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzeStyle8DFromWorkerLogic {
- return &AnalyzeStyle8DFromWorkerLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *AnalyzeStyle8DFromWorkerLogic) AnalyzeStyle8DFromWorker(req *types.AnalyzeStyle8DReq) (resp *types.AnalyzeStyle8DData, err error) {
- var credential *tausecase.WorkerAiCredential
- accountID := strings.TrimSpace(req.ThreadsAccountID)
- if accountID != "" {
- credential, err = l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(
- l.ctx,
- req.TenantID,
- req.OwnerUID,
- accountID,
- )
- } else {
- credential, err = l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential(
- l.ctx,
- req.TenantID,
- req.OwnerUID,
- )
- }
- if err != nil {
- return nil, err
- }
- providerID, err := mapWorkerAIProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- posts := make([]style8d.Post, 0, len(req.Posts))
- for _, item := range req.Posts {
- text := strings.TrimSpace(item.Text)
- if text == "" {
- continue
- }
- posts = append(posts, style8d.Post{
- Text: text,
- Permalink: strings.TrimSpace(item.Permalink),
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- })
- }
- if len(posts) == 0 {
- return nil, app.For(code.Persona).InputInvalidFormat("posts contain no readable text")
- }
-
- steps := toEntitySteps(req.Steps)
- steps = markWorkerStep(steps, "style", jobenum.StepStatusRunning, "AI 正在分析 D1–D8…")
- _, _ = l.svcCtx.Job.UpdateProgress(l.ctx, jobusecase.UpdateProgressRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- Phase: "style",
- Summary: "AI 正在分析八個風格維度…",
- Percentage: 55,
- Steps: steps,
- })
-
- username := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
- input := style8d.AnalyzeInput{
- Mode: style8d.AnalyzeModeBenchmark,
- Username: username,
- Posts: posts,
- }
- systemPrompt, err := libprompt.Style8DSystem()
- if err != nil {
- return nil, app.For(code.AI).SysInternal("prompt config load failed")
- }
- maxTokens := 8192
- result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: input.BuildUserPrompt()},
- },
- MaxTokens: &maxTokens,
- })
- if err != nil {
- if strings.Contains(err.Error(), "HTTP 401") {
- err = app.For(code.AI).SvcThirdParty(
- "8D AI 分析授權失敗:目前帳號的研究用 Provider API key 無效或未授權。請到「設定 > 帳號 AI 設定」確認 research provider=" +
- credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
- )
- }
- return nil, err
- }
-
- parsed, err := style8d.ParseLLMOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error())
- }
- profile := input.BuildStoredProfile(parsed)
- profileJSON, err := profile.JSON()
- if err != nil {
- return nil, err
- }
-
- steps = markWorkerStep(steps, "style", jobenum.StepStatusSucceeded, "8D 風格策略已產生")
- steps = markWorkerStep(steps, "store", jobenum.StepStatusRunning, "寫入人設風格策略…")
- _, _ = l.svcCtx.Job.UpdateProgress(l.ctx, jobusecase.UpdateProgressRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- Phase: "store",
- Summary: "8D 分析完成,寫入人設…",
- Percentage: 88,
- Steps: steps,
- })
-
- personaDraft := strings.TrimSpace(profile.PersonaDraft)
- benchmark := input.StyleBenchmark()
- patch := personausecase.PersonaPatch{
- StyleProfile: &profileJSON,
- StyleBenchmark: &benchmark,
- }
- if personaDraft != "" {
- patch.Persona = &personaDraft
- }
- _, err = l.svcCtx.Persona.Update(l.ctx, personausecase.UpdateRequest{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.PersonaID,
- Patch: patch,
- })
- if err != nil {
- return nil, err
- }
-
- steps = markWorkerStep(steps, "store", jobenum.StepStatusSucceeded, "8D 策略已寫入人設")
- _, _ = l.svcCtx.Job.UpdateProgress(l.ctx, jobusecase.UpdateProgressRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- Phase: "store",
- Summary: "8D 策略已寫入人設",
- Percentage: 92,
- Steps: steps,
- })
-
- return &types.AnalyzeStyle8DData{
- PersonaID: req.PersonaID,
- PostCount: len(posts),
- StyleProfile: profileJSON,
- StyleBenchmark: username,
- }, nil
-}
diff --git a/old/backend/internal/logic/job/cancel_job_logic.go b/old/backend/internal/logic/job/cancel_job_logic.go
deleted file mode 100644
index 39e0877..0000000
--- a/old/backend/internal/logic/job/cancel_job_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package job
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type CancelJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCancelJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelJobLogic {
- return &CancelJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CancelJobLogic) CancelJob(req *types.CancelJobReq) (*types.JobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- existing, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- if err := ensureRunAccess(existing, tenantID, uid); err != nil {
- return nil, err
- }
- run, err := l.svcCtx.Job.RequestCancel(l.ctx, domusecase.CancelRunRequest{
- JobID: req.ID,
- Reason: req.Reason,
- })
- if err != nil {
- return nil, err
- }
- data := toJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/check_worker_job_cancel_logic.go b/old/backend/internal/logic/job/check_worker_job_cancel_logic.go
deleted file mode 100644
index a02cc57..0000000
--- a/old/backend/internal/logic/job/check_worker_job_cancel_logic.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CheckWorkerJobCancelLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCheckWorkerJobCancelLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CheckWorkerJobCancelLogic {
- return &CheckWorkerJobCancelLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CheckWorkerJobCancelLogic) CheckWorkerJobCancel(req *types.WorkerJobReq) (resp *types.WorkerCancelCheckData, err error) {
- cancelled, err := l.svcCtx.Job.IsCancelRequested(l.ctx, req.ID)
- return &types.WorkerCancelCheckData{Cancelled: cancelled}, err
-}
diff --git a/old/backend/internal/logic/job/claim_worker_job_logic.go b/old/backend/internal/logic/job/claim_worker_job_logic.go
deleted file mode 100644
index e9c3a81..0000000
--- a/old/backend/internal/logic/job/claim_worker_job_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- jobusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ClaimWorkerJobLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewClaimWorkerJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimWorkerJobLogic {
- return &ClaimWorkerJobLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ClaimWorkerJobLogic) ClaimWorkerJob(req *types.ClaimWorkerJobReq) (resp *types.JobData, err error) {
- run, err := l.svcCtx.Job.ClaimNext(l.ctx, jobusecase.ClaimNextRequest{
- WorkerType: req.WorkerType,
- WorkerID: req.WorkerID,
- })
- if err != nil || run == nil {
- return nil, err
- }
- data := ToJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/complete_worker_job_logic.go b/old/backend/internal/logic/job/complete_worker_job_logic.go
deleted file mode 100644
index a560b8e..0000000
--- a/old/backend/internal/logic/job/complete_worker_job_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- jobusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CompleteWorkerJobLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCompleteWorkerJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CompleteWorkerJobLogic {
- return &CompleteWorkerJobLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CompleteWorkerJobLogic) CompleteWorkerJob(req *types.WorkerCompleteReq) (resp *types.JobData, err error) {
- run, err := l.svcCtx.Job.CompleteRun(l.ctx, jobusecase.CompleteRunRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- Result: req.Result,
- })
- if err != nil {
- return nil, err
- }
- data := ToJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/create_job_logic.go b/old/backend/internal/logic/job/create_job_logic.go
deleted file mode 100644
index 2046b54..0000000
--- a/old/backend/internal/logic/job/create_job_logic.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package job
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type CreateJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJobLogic {
- return &CreateJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CreateJobLogic) CreateJob(req *types.CreateJobReq) (*types.JobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- payload := req.Payload
- if payload == nil {
- payload = map[string]any{}
- }
- if _, ok := payload["tenant_id"]; !ok && tenantID != "" {
- payload["tenant_id"] = tenantID
- }
- if _, ok := payload["owner_uid"]; !ok && uid != "" {
- payload["owner_uid"] = uid
- }
- scopeID := req.ScopeID
- if req.Scope == "user" && scopeID == "" {
- scopeID = uid
- }
- run, err := l.svcCtx.Job.CreateRun(l.ctx, domusecase.CreateRunRequest{
- TemplateType: req.TemplateType,
- Scope: req.Scope,
- ScopeID: scopeID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- data := toJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/create_job_schedule_logic.go b/old/backend/internal/logic/job/create_job_schedule_logic.go
deleted file mode 100644
index 8737148..0000000
--- a/old/backend/internal/logic/job/create_job_schedule_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package job
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type CreateJobScheduleLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJobScheduleLogic {
- return &CreateJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CreateJobScheduleLogic) CreateJobSchedule(req *types.CreateJobScheduleReq) (*types.JobScheduleData, error) {
- schedule, err := l.svcCtx.Job.CreateSchedule(l.ctx, domusecase.CreateScheduleRequest{
- TemplateType: req.TemplateType,
- Scope: req.Scope,
- ScopeID: req.ScopeID,
- Cron: req.Cron,
- Timezone: req.Timezone,
- PayloadTemplate: req.PayloadTemplate,
- Enabled: req.Enabled,
- })
- if err != nil {
- return nil, err
- }
- data := toJobScheduleData(schedule)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/delete_job_schedule_logic.go b/old/backend/internal/logic/job/delete_job_schedule_logic.go
deleted file mode 100644
index 6b904aa..0000000
--- a/old/backend/internal/logic/job/delete_job_schedule_logic.go
+++ /dev/null
@@ -1,31 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeleteJobScheduleLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteJobScheduleLogic {
- return &DeleteJobScheduleLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *DeleteJobScheduleLogic) DeleteJobSchedule(req *types.JobScheduleIDPath) error {
- return l.svcCtx.Job.DeleteSchedule(l.ctx, req.ID)
-}
diff --git a/old/backend/internal/logic/job/disable_job_schedule_logic.go b/old/backend/internal/logic/job/disable_job_schedule_logic.go
deleted file mode 100644
index d1847f8..0000000
--- a/old/backend/internal/logic/job/disable_job_schedule_logic.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DisableJobScheduleLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDisableJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisableJobScheduleLogic {
- return &DisableJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DisableJobScheduleLogic) DisableJobSchedule(req *types.JobScheduleIDPath) (*types.JobScheduleData, error) {
- schedule, err := l.svcCtx.Job.DisableSchedule(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- data := toJobScheduleData(schedule)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/enable_job_schedule_logic.go b/old/backend/internal/logic/job/enable_job_schedule_logic.go
deleted file mode 100644
index 8e258b9..0000000
--- a/old/backend/internal/logic/job/enable_job_schedule_logic.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type EnableJobScheduleLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewEnableJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *EnableJobScheduleLogic {
- return &EnableJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *EnableJobScheduleLogic) EnableJobSchedule(req *types.JobScheduleIDPath) (*types.JobScheduleData, error) {
- schedule, err := l.svcCtx.Job.EnableSchedule(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- data := toJobScheduleData(schedule)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/fail_worker_job_logic.go b/old/backend/internal/logic/job/fail_worker_job_logic.go
deleted file mode 100644
index 077ff6b..0000000
--- a/old/backend/internal/logic/job/fail_worker_job_logic.go
+++ /dev/null
@@ -1,42 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- jobusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type FailWorkerJobLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewFailWorkerJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FailWorkerJobLogic {
- return &FailWorkerJobLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *FailWorkerJobLogic) FailWorkerJob(req *types.WorkerFailReq) (resp *types.JobData, err error) {
- run, err := l.svcCtx.Job.FailRun(l.ctx, jobusecase.FailRunRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- Error: req.Error,
- Phase: req.Phase,
- })
- if err != nil {
- return nil, err
- }
- data := ToJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/get_job_logic.go b/old/backend/internal/logic/job/get_job_logic.go
deleted file mode 100644
index 1d1da4a..0000000
--- a/old/backend/internal/logic/job/get_job_logic.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobLogic {
- return &GetJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetJobLogic) GetJob(req *types.JobIDPath) (*types.JobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- run, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- if err := ensureRunAccess(run, tenantID, uid); err != nil {
- return nil, err
- }
- data := toJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/get_job_template_logic.go b/old/backend/internal/logic/job/get_job_template_logic.go
deleted file mode 100644
index 107f81d..0000000
--- a/old/backend/internal/logic/job/get_job_template_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetJobTemplateLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetJobTemplateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobTemplateLogic {
- return &GetJobTemplateLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetJobTemplateLogic) GetJobTemplate(req *types.JobTemplatePath) (*types.JobTemplateData, error) {
- if err := requireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- template, err := l.svcCtx.Job.GetTemplate(l.ctx, req.Type)
- if err != nil {
- return nil, err
- }
- data := toJobTemplateData(template)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/get_worker_threads_account_session_logic.go b/old/backend/internal/logic/job/get_worker_threads_account_session_logic.go
deleted file mode 100644
index 21312d1..0000000
--- a/old/backend/internal/logic/job/get_worker_threads_account_session_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetWorkerThreadsAccountSessionLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetWorkerThreadsAccountSessionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetWorkerThreadsAccountSessionLogic {
- return &GetWorkerThreadsAccountSessionLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetWorkerThreadsAccountSessionLogic) GetWorkerThreadsAccountSession(req *types.WorkerThreadsAccountSessionReq) (resp *types.WorkerThreadsAccountSessionData, err error) {
- session, err := l.svcCtx.ThreadsAccount.GetBrowserSession(l.ctx, req.TenantID, req.OwnerUID, req.ID)
- if err != nil {
- return nil, err
- }
- return &types.WorkerThreadsAccountSessionData{
- AccountID: session.AccountID,
- StorageState: session.StorageState,
- UpdateAt: session.UpdateAt,
- }, nil
-}
diff --git a/old/backend/internal/logic/job/list_job_events_logic.go b/old/backend/internal/logic/job/list_job_events_logic.go
deleted file mode 100644
index 3f105af..0000000
--- a/old/backend/internal/logic/job/list_job_events_logic.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListJobEventsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListJobEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobEventsLogic {
- return &ListJobEventsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListJobEventsLogic) ListJobEvents(req *types.ListJobEventsReq) (*types.JobEventListData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- run, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- if err := ensureRunAccess(run, tenantID, uid); err != nil {
- return nil, err
- }
- limit := req.Limit
- if limit <= 0 {
- limit = 50
- }
- events, err := l.svcCtx.Job.ListJobEvents(l.ctx, req.ID, limit)
- if err != nil {
- return nil, err
- }
- list := make([]types.JobEventData, 0, len(events))
- for _, event := range events {
- list = append(list, toJobEventData(event))
- }
- return &types.JobEventListData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/job/list_job_schedules_logic.go b/old/backend/internal/logic/job/list_job_schedules_logic.go
deleted file mode 100644
index 23c9b51..0000000
--- a/old/backend/internal/logic/job/list_job_schedules_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListJobSchedulesLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListJobSchedulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobSchedulesLogic {
- return &ListJobSchedulesLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListJobSchedulesLogic) ListJobSchedules(req *types.ListJobSchedulesReq) (*types.JobScheduleListData, error) {
- items, total, page, pageSize, totalPages, err := l.svcCtx.Job.ListSchedules(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
- if err != nil {
- return nil, err
- }
- list := make([]types.JobScheduleData, 0, len(items))
- for _, item := range items {
- list = append(list, toJobScheduleData(item))
- }
- return &types.JobScheduleListData{
- Pagination: types.PaginationData{
- Total: total,
- Page: page,
- PageSize: pageSize,
- TotalPages: totalPages,
- },
- List: list,
- }, nil
-}
diff --git a/old/backend/internal/logic/job/list_job_templates_logic.go b/old/backend/internal/logic/job/list_job_templates_logic.go
deleted file mode 100644
index a50d67a..0000000
--- a/old/backend/internal/logic/job/list_job_templates_logic.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListJobTemplatesLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListJobTemplatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobTemplatesLogic {
- return &ListJobTemplatesLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListJobTemplatesLogic) ListJobTemplates() (*types.JobTemplateListData, error) {
- if err := requireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- templates, err := l.svcCtx.Job.ListTemplates(l.ctx)
- if err != nil {
- return nil, err
- }
- list := make([]types.JobTemplateData, 0, len(templates))
- for _, template := range templates {
- list = append(list, toJobTemplateData(template))
- }
- return &types.JobTemplateListData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/job/list_jobs_logic.go b/old/backend/internal/logic/job/list_jobs_logic.go
deleted file mode 100644
index a0e7259..0000000
--- a/old/backend/internal/logic/job/list_jobs_logic.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package job
-
-import (
- "context"
-
- domrepo "haixun-backend/internal/model/job/domain/repository"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListJobsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListJobsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobsLogic {
- return &ListJobsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListJobsLogic) ListJobs(req *types.ListJobsReq) (*types.JobListData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- runs, total, page, pageSize, totalPages, err := l.svcCtx.Job.ListRuns(l.ctx, domrepo.RunListFilter{
- Scope: req.Scope,
- ScopeID: req.ScopeID,
- TenantID: tenantID,
- OwnerUID: uid,
- }, req.Page, req.PageSize)
- if err != nil {
- return nil, err
- }
- list := make([]types.JobData, 0, len(runs))
- for _, run := range runs {
- list = append(list, toJobData(run))
- }
- return &types.JobListData{
- Pagination: types.PaginationData{
- Total: total,
- Page: page,
- PageSize: pageSize,
- TotalPages: totalPages,
- },
- List: list,
- }, nil
-}
diff --git a/old/backend/internal/logic/job/mapper.go b/old/backend/internal/logic/job/mapper.go
deleted file mode 100644
index 5775675..0000000
--- a/old/backend/internal/logic/job/mapper.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package job
-
-import (
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/types"
-)
-
-func toJobTemplateData(template *entity.Template) types.JobTemplateData {
- steps := make([]types.JobTemplateStepData, 0, len(template.Steps))
- for _, step := range template.Steps {
- steps = append(steps, types.JobTemplateStepData{
- ID: step.ID,
- Name: step.Name,
- WorkerType: step.WorkerType,
- TimeoutSeconds: step.TimeoutSeconds,
- Cancelable: step.Cancelable,
- })
- }
- return types.JobTemplateData{
- Type: template.Type,
- Version: template.Version,
- Name: template.Name,
- Description: template.Description,
- Enabled: template.Enabled,
- Repeatable: template.Repeatable,
- ConcurrencyPolicy: template.ConcurrencyPolicy,
- DedupeKeys: template.DedupeKeys,
- TimeoutSeconds: template.TimeoutSeconds,
- CancelPolicy: types.JobCancelPolicyData{
- Supported: template.CancelPolicy.Supported,
- Mode: template.CancelPolicy.Mode,
- GraceSeconds: template.CancelPolicy.GraceSeconds,
- },
- RetryPolicy: types.JobRetryPolicyData{
- MaxAttempts: template.RetryPolicy.MaxAttempts,
- BackoffSeconds: template.RetryPolicy.BackoffSeconds,
- },
- Steps: steps,
- }
-}
-
-func toJobData(run *entity.Run) types.JobData {
- steps := make([]types.JobStepProgressData, 0, len(run.Progress.Steps))
- for _, step := range run.Progress.Steps {
- steps = append(steps, types.JobStepProgressData{
- ID: step.ID,
- Status: string(step.Status),
- StartedAt: step.StartedAt,
- EndedAt: step.EndedAt,
- Message: step.Message,
- })
- }
- return types.JobData{
- ID: run.ID.Hex(),
- TemplateType: run.TemplateType,
- TemplateVersion: run.TemplateVersion,
- Scope: run.Scope,
- ScopeID: run.ScopeID,
- Status: string(run.Status),
- Phase: run.Phase,
- WorkerType: run.WorkerType,
- Payload: run.Payload,
- Progress: types.JobProgressData{
- Summary: run.Progress.Summary,
- Percentage: run.Progress.Percentage,
- Steps: steps,
- },
- Result: run.Result,
- Error: run.Error,
- Attempt: run.Attempt,
- MaxAttempts: run.MaxAttempts,
- CancelRequestedAt: run.CancelRequestedAt,
- CancelReason: run.CancelReason,
- StartedAt: run.StartedAt,
- CompletedAt: run.CompletedAt,
- CreateAt: run.CreateAt,
- UpdateAt: run.UpdateAt,
- }
-}
-
-func ToJobData(run *entity.Run) types.JobData {
- return toJobData(run)
-}
-
-func toJobScheduleData(schedule *entity.Schedule) types.JobScheduleData {
- return types.JobScheduleData{
- ID: schedule.ID.Hex(),
- TemplateType: schedule.TemplateType,
- Scope: schedule.Scope,
- ScopeID: schedule.ScopeID,
- Enabled: schedule.Enabled,
- Cron: schedule.Cron,
- Timezone: schedule.Timezone,
- PayloadTemplate: schedule.PayloadTemplate,
- LastRunAt: schedule.LastRunAt,
- NextRunAt: schedule.NextRunAt,
- CreateAt: schedule.CreateAt,
- UpdateAt: schedule.UpdateAt,
- }
-}
-
-func toJobEventData(event *entity.Event) types.JobEventData {
- return types.JobEventData{
- ID: event.ID.Hex(),
- JobID: event.JobID,
- Type: event.Type,
- From: event.From,
- To: event.To,
- Message: event.Message,
- Metadata: event.Metadata,
- CreateAt: event.CreateAt,
- }
-}
-
-func toTemplateSteps(steps []types.JobTemplateStepData) []entity.TemplateStep {
- out := make([]entity.TemplateStep, 0, len(steps))
- for _, step := range steps {
- out = append(out, entity.TemplateStep{
- ID: step.ID,
- Name: step.Name,
- WorkerType: step.WorkerType,
- TimeoutSeconds: step.TimeoutSeconds,
- Cancelable: step.Cancelable,
- })
- }
- return out
-}
diff --git a/old/backend/internal/logic/job/ownership.go b/old/backend/internal/logic/job/ownership.go
deleted file mode 100644
index ab70b97..0000000
--- a/old/backend/internal/logic/job/ownership.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package job
-
-import (
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-func runOwnedBy(run *entity.Run, tenantID, uid string) bool {
- if run == nil || strings.TrimSpace(uid) == "" {
- return false
- }
- ownerUID := strings.TrimSpace(run.OwnerUID)
- runTenantID := strings.TrimSpace(run.TenantID)
- if ownerUID == "" && run.Payload != nil {
- ownerUID = stringFromPayload(run.Payload, "owner_uid")
- runTenantID = stringFromPayload(run.Payload, "tenant_id")
- }
- if ownerUID != "" {
- if ownerUID != uid {
- return false
- }
- if runTenantID != "" && tenantID != "" && runTenantID != tenantID {
- return false
- }
- return true
- }
- return run.Scope == "user" && run.ScopeID == uid
-}
-
-func ensureRunAccess(run *entity.Run, tenantID, uid string) error {
- if runOwnedBy(run, tenantID, uid) {
- return nil
- }
- return app.For(code.Job).ResNotFound("job run not found")
-}
-
-func stringFromPayload(payload map[string]any, key string) string {
- if payload == nil {
- return ""
- }
- value, ok := payload[key]
- if !ok || value == nil {
- return ""
- }
- text, ok := value.(string)
- if !ok {
- return ""
- }
- return strings.TrimSpace(text)
-}
diff --git a/old/backend/internal/logic/job/ownership_test.go b/old/backend/internal/logic/job/ownership_test.go
deleted file mode 100644
index 559c3a7..0000000
--- a/old/backend/internal/logic/job/ownership_test.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package job
-
-import (
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-func TestRunOwnedByTopLevelFields(t *testing.T) {
- run := &entity.Run{
- TenantID: "tenant-a",
- OwnerUID: "user-1",
- }
- if !runOwnedBy(run, "tenant-a", "user-1") {
- t.Fatal("expected top-level owner match")
- }
- if runOwnedBy(run, "tenant-a", "user-2") {
- t.Fatal("expected different uid to be rejected")
- }
-}
-
-func TestRunOwnedByPayloadFallback(t *testing.T) {
- run := &entity.Run{
- Payload: map[string]any{
- "tenant_id": "tenant-a",
- "owner_uid": "user-1",
- },
- }
- if !runOwnedBy(run, "tenant-a", "user-1") {
- t.Fatal("expected payload owner match")
- }
-}
-
-func TestRunOwnedByUserScopeLegacy(t *testing.T) {
- run := &entity.Run{
- Scope: "user",
- ScopeID: "user-1",
- }
- if !runOwnedBy(run, "tenant-a", "user-1") {
- t.Fatal("expected legacy user scope match")
- }
-}
diff --git a/old/backend/internal/logic/job/refresh_worker_job_lock_logic.go b/old/backend/internal/logic/job/refresh_worker_job_lock_logic.go
deleted file mode 100644
index 0f312f1..0000000
--- a/old/backend/internal/logic/job/refresh_worker_job_lock_logic.go
+++ /dev/null
@@ -1,36 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type RefreshWorkerJobLockLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRefreshWorkerJobLockLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefreshWorkerJobLockLogic {
- return &RefreshWorkerJobLockLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *RefreshWorkerJobLockLogic) RefreshWorkerJobLock(req *types.WorkerHeartbeatReq) (resp *types.WorkerOKData, err error) {
- ttl := req.TTLSeconds
- if ttl <= 0 {
- ttl = 300
- }
- err = l.svcCtx.Job.RefreshRunLock(l.ctx, req.ID, req.WorkerID, ttl)
- return &types.WorkerOKData{OK: err == nil}, err
-}
diff --git a/old/backend/internal/logic/job/retry_job_logic.go b/old/backend/internal/logic/job/retry_job_logic.go
deleted file mode 100644
index 700bdef..0000000
--- a/old/backend/internal/logic/job/retry_job_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type RetryJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRetryJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryJobLogic {
- return &RetryJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *RetryJobLogic) RetryJob(req *types.JobIDPath) (*types.JobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- existing, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- if err := ensureRunAccess(existing, tenantID, uid); err != nil {
- return nil, err
- }
- run, err := l.svcCtx.Job.RetryRun(l.ctx, req.ID)
- if err != nil {
- return nil, err
- }
- data := toJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/store_persona_style_profile_from_worker_logic.go b/old/backend/internal/logic/job/store_persona_style_profile_from_worker_logic.go
deleted file mode 100644
index 54b8ede..0000000
--- a/old/backend/internal/logic/job/store_persona_style_profile_from_worker_logic.go
+++ /dev/null
@@ -1,52 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- personausecase "haixun-backend/internal/model/persona/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type StorePersonaStyleProfileFromWorkerLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStorePersonaStyleProfileFromWorkerLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StorePersonaStyleProfileFromWorkerLogic {
- return &StorePersonaStyleProfileFromWorkerLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *StorePersonaStyleProfileFromWorkerLogic) StorePersonaStyleProfileFromWorker(req *types.StorePersonaStyleProfileReq) (resp *types.StorePersonaStyleProfileData, err error) {
- if strings.TrimSpace(req.StyleProfile) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("style_profile is required")
- }
- profile := strings.TrimSpace(req.StyleProfile)
- benchmark := strings.TrimPrefix(strings.TrimSpace(req.StyleBenchmark), "@")
- item, err := l.svcCtx.Persona.Update(l.ctx, personausecase.UpdateRequest{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.ID,
- Patch: personausecase.PersonaPatch{
- StyleProfile: &profile,
- StyleBenchmark: &benchmark,
- },
- })
- if err != nil {
- return nil, err
- }
- return &types.StorePersonaStyleProfileData{ID: item.ID, UpdateAt: item.UpdateAt}, nil
-}
diff --git a/old/backend/internal/logic/job/update_job_schedule_logic.go b/old/backend/internal/logic/job/update_job_schedule_logic.go
deleted file mode 100644
index 26a1273..0000000
--- a/old/backend/internal/logic/job/update_job_schedule_logic.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package job
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type UpdateJobScheduleLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateJobScheduleLogic {
- return &UpdateJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdateJobScheduleLogic) UpdateJobSchedule(req *types.UpdateJobScheduleReq) (*types.JobScheduleData, error) {
- schedule, err := l.svcCtx.Job.UpdateSchedule(l.ctx, domusecase.UpdateScheduleRequest{
- ID: req.ID,
- Cron: req.Cron,
- Timezone: req.Timezone,
- PayloadTemplate: req.PayloadTemplate,
- Enabled: req.Enabled,
- })
- if err != nil {
- return nil, err
- }
- data := toJobScheduleData(schedule)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/update_worker_job_progress_logic.go b/old/backend/internal/logic/job/update_worker_job_progress_logic.go
deleted file mode 100644
index 0265d92..0000000
--- a/old/backend/internal/logic/job/update_worker_job_progress_logic.go
+++ /dev/null
@@ -1,44 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package job
-
-import (
- "context"
-
- jobusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateWorkerJobProgressLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateWorkerJobProgressLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateWorkerJobProgressLogic {
- return &UpdateWorkerJobProgressLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateWorkerJobProgressLogic) UpdateWorkerJobProgress(req *types.WorkerProgressReq) (resp *types.JobData, err error) {
- run, err := l.svcCtx.Job.UpdateProgress(l.ctx, jobusecase.UpdateProgressRequest{
- JobID: req.ID,
- WorkerID: req.WorkerID,
- Phase: req.Phase,
- Summary: req.Summary,
- Percentage: req.Percentage,
- Steps: toEntitySteps(req.Steps),
- })
- if err != nil {
- return nil, err
- }
- data := ToJobData(run)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/upsert_job_template_logic.go b/old/backend/internal/logic/job/upsert_job_template_logic.go
deleted file mode 100644
index 46a46a6..0000000
--- a/old/backend/internal/logic/job/upsert_job_template_logic.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package job
-
-import (
- "context"
-
- "haixun-backend/internal/model/job/domain/entity"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type UpsertJobTemplateLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertJobTemplateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertJobTemplateLogic {
- return &UpsertJobTemplateLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpsertJobTemplateLogic) UpsertJobTemplate(req *types.UpsertJobTemplateReq) (*types.JobTemplateData, error) {
- if err := requireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- template, err := l.svcCtx.Job.UpsertTemplate(l.ctx, domusecase.UpsertTemplateRequest{
- Type: req.Type,
- Version: req.Version,
- Name: req.Name,
- Description: req.Description,
- Enabled: req.Enabled,
- Repeatable: req.Repeatable,
- ConcurrencyPolicy: req.ConcurrencyPolicy,
- DedupeKeys: req.DedupeKeys,
- TimeoutSeconds: req.TimeoutSeconds,
- CancelPolicy: entity.CancelPolicy{
- Supported: req.CancelPolicy.Supported,
- Mode: req.CancelPolicy.Mode,
- GraceSeconds: req.CancelPolicy.GraceSeconds,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: req.RetryPolicy.MaxAttempts,
- BackoffSeconds: req.RetryPolicy.BackoffSeconds,
- },
- Steps: toTemplateSteps(req.Steps),
- })
- if err != nil {
- return nil, err
- }
- data := toJobTemplateData(template)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/job/worker_helpers.go b/old/backend/internal/logic/job/worker_helpers.go
deleted file mode 100644
index 6e6d775..0000000
--- a/old/backend/internal/logic/job/worker_helpers.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package job
-
-import (
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/ai/domain/enum"
- jobentity "haixun-backend/internal/model/job/domain/entity"
- jobenum "haixun-backend/internal/model/job/domain/enum"
- "haixun-backend/internal/types"
-)
-
-func mapWorkerAIProvider(provider string) (enum.ProviderID, error) {
- switch strings.TrimSpace(provider) {
- case string(enum.ProviderOpenCode):
- return enum.ProviderOpenCode, nil
- case string(enum.ProviderXAI):
- return enum.ProviderXAI, nil
- default:
- return "", app.For(code.AI).InputInvalidFormat("worker 8D 分析目前僅支援 opencode-go 與 xai,請在 AI 設定調整 research provider")
- }
-}
-
-func markWorkerStep(steps []jobentity.StepProgress, stepID string, status jobenum.StepStatus, message string) []jobentity.StepProgress {
- now := clock.NowUnixNano()
- found := false
- for i := range steps {
- if steps[i].ID != stepID {
- continue
- }
- found = true
- steps[i].Status = status
- steps[i].Message = message
- if status == jobenum.StepStatusRunning && steps[i].StartedAt == nil {
- steps[i].StartedAt = &now
- }
- if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
- steps[i].EndedAt = &now
- }
- }
- if !found {
- item := jobentity.StepProgress{ID: stepID, Status: status, Message: message}
- if status == jobenum.StepStatusRunning {
- item.StartedAt = &now
- }
- if status == jobenum.StepStatusSucceeded || status == jobenum.StepStatusFailed {
- item.EndedAt = &now
- }
- steps = append(steps, item)
- }
- return steps
-}
-
-func toEntitySteps(steps []types.JobStepProgressData) []jobentity.StepProgress {
- out := make([]jobentity.StepProgress, 0, len(steps))
- for _, step := range steps {
- out = append(out, jobentity.StepProgress{
- ID: step.ID,
- Status: jobenum.StepStatus(step.Status),
- StartedAt: step.StartedAt,
- EndedAt: step.EndedAt,
- Message: step.Message,
- })
- }
- return out
-}
diff --git a/old/backend/internal/logic/member/ai_settings_mapper.go b/old/backend/internal/logic/member/ai_settings_mapper.go
deleted file mode 100644
index a999a99..0000000
--- a/old/backend/internal/logic/member/ai_settings_mapper.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package member
-
-import (
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toMemberAiSettingsData(data *domusecase.AiSettings) *types.MemberAiSettingsData {
- if data == nil {
- return nil
- }
- configured := map[string]interface{}{}
- for provider, ok := range data.ApiKeysConfigured {
- configured[provider] = ok
- }
- return &types.MemberAiSettingsData{
- Provider: data.Provider,
- Model: data.Model,
- ResearchProvider: data.ResearchProvider,
- ResearchModel: data.ResearchModel,
- ApiKeys: data.ApiKeys,
- ApiKeysConfigured: configured,
- }
-}
-
-func toMemberAiSettingsPatch(req *types.UpdateMemberAiSettingsReq) domusecase.AiSettingsPatch {
- if req == nil {
- return domusecase.AiSettingsPatch{}
- }
- return domusecase.AiSettingsPatch{
- Provider: req.Provider,
- Model: req.Model,
- ResearchProvider: req.ResearchProvider,
- ResearchModel: req.ResearchModel,
- ApiKeys: req.ApiKeys,
- }
-}
diff --git a/old/backend/internal/logic/member/avatar_ref.go b/old/backend/internal/logic/member/avatar_ref.go
deleted file mode 100644
index 88b5089..0000000
--- a/old/backend/internal/logic/member/avatar_ref.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package member
-
-import (
- "haixun-backend/internal/library/storage"
- "haixun-backend/internal/model/member/domain/entity"
- "haixun-backend/internal/types"
-)
-
-func normalizeAvatarRef(bucket, ref string) string {
- return storage.NormalizeObjectKey(bucket, ref)
-}
-
-func optionalNormalizedAvatar(bucket, value string) *string {
- if value == "" {
- return nil
- }
- normalized := normalizeAvatarRef(bucket, value)
- return &normalized
-}
-
-func optionalNormalizedAvatarPtr(bucket string, value *string) *string {
- if value == nil {
- return nil
- }
- return optionalNormalizedAvatar(bucket, *value)
-}
-
-func toMemberMeDataWithAvatar(member *entity.Member, bucket string) *types.MemberMeData {
- data := toMemberMeData(member)
- if data == nil || bucket == "" {
- return data
- }
- data.Avatar = normalizeAvatarRef(bucket, data.Avatar)
- return data
-}
-
-func toMemberMeDataListWithAvatar(members []*entity.Member, bucket string) []types.MemberMeData {
- list := make([]types.MemberMeData, 0, len(members))
- for _, item := range members {
- data := toMemberMeDataWithAvatar(item, bucket)
- if data == nil {
- continue
- }
- list = append(list, *data)
- }
- return list
-}
diff --git a/old/backend/internal/logic/member/get_member_ai_settings_logic.go b/old/backend/internal/logic/member/get_member_ai_settings_logic.go
deleted file mode 100644
index afc0777..0000000
--- a/old/backend/internal/logic/member/get_member_ai_settings_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetMemberAiSettingsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetMemberAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberAiSettingsLogic {
- return &GetMemberAiSettingsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetMemberAiSettingsLogic) GetMemberAiSettings() (resp *types.MemberAiSettingsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- data, err := l.svcCtx.ThreadsAccount.GetMemberAiSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- return toMemberAiSettingsData(data), nil
-}
diff --git a/old/backend/internal/logic/member/get_member_capabilities_logic.go b/old/backend/internal/logic/member/get_member_capabilities_logic.go
deleted file mode 100644
index 99d2f13..0000000
--- a/old/backend/internal/logic/member/get_member_capabilities_logic.go
+++ /dev/null
@@ -1,51 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetMemberCapabilitiesLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetMemberCapabilitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberCapabilitiesLogic {
- return &GetMemberCapabilitiesLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetMemberCapabilitiesLogic) GetMemberCapabilities() (*types.MemberCapabilitiesData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- caps, err := l.svcCtx.ThreadsAccount.ResolveMemberCapabilities(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
- return &types.MemberCapabilitiesData{
- DiscoverReady: caps.DiscoverReady,
- AiReady: caps.AiReady,
- PublishReady: caps.PublishReady,
- DiscoverHint: caps.DiscoverHint,
- AiHint: caps.AiHint,
- PublishHint: caps.PublishHint,
- ActiveThreadsAccountId: caps.ActiveThreadsAccount,
- }, nil
-}
diff --git a/old/backend/internal/logic/member/get_member_me_logic.go b/old/backend/internal/logic/member/get_member_me_logic.go
deleted file mode 100644
index c4eae76..0000000
--- a/old/backend/internal/logic/member/get_member_me_logic.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetMemberMeLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetMemberMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberMeLogic {
- return &GetMemberMeLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetMemberMeLogic) GetMemberMe() (*types.MemberMeData, error) {
- actor, ok := authctx.ActorFromContext(l.ctx)
- if !ok {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- member, err := l.svcCtx.Member.GetByUID(l.ctx, actor.TenantID, actor.UID)
- if err != nil {
- return nil, err
- }
- return toMemberMeDataWithAvatar(member, l.svcCtx.Config.Storage.S3.Bucket), nil
-}
diff --git a/old/backend/internal/logic/member/get_member_placement_settings_logic.go b/old/backend/internal/logic/member/get_member_placement_settings_logic.go
deleted file mode 100644
index ab60637..0000000
--- a/old/backend/internal/logic/member/get_member_placement_settings_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetMemberPlacementSettingsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetMemberPlacementSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberPlacementSettingsLogic {
- return &GetMemberPlacementSettingsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetMemberPlacementSettingsLogic) GetMemberPlacementSettings() (resp *types.MemberPlacementSettingsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- settings, err := l.svcCtx.Placement.Get(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- data := toPlacementSettingsData(settings)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/member/list_member_ai_provider_models_logic.go b/old/backend/internal/logic/member/list_member_ai_provider_models_logic.go
deleted file mode 100644
index 2a78bc4..0000000
--- a/old/backend/internal/logic/member/list_member_ai_provider_models_logic.go
+++ /dev/null
@@ -1,48 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/model/ai/domain/enum"
- aiuc "haixun-backend/internal/model/ai/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListMemberAiProviderModelsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListMemberAiProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMemberAiProviderModelsLogic {
- return &ListMemberAiProviderModelsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListMemberAiProviderModelsLogic) ListMemberAiProviderModels(req *types.MemberAiProviderModelsReq) (resp *types.MemberAiProviderModelsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- apiKey, err := l.svcCtx.ThreadsAccount.ResolveMemberAiProviderAPIKey(l.ctx, tenantID, uid, req.Provider, req.ApiKey)
- if err != nil {
- return nil, err
- }
- result := l.svcCtx.AI.ListProviderModels(l.ctx, enum.ProviderID(req.Provider), aiuc.Credential{APIKey: apiKey})
- return &types.MemberAiProviderModelsData{
- ID: result.ID,
- Label: result.Label,
- Models: result.Models,
- Streams: result.Streams,
- Error: result.Error,
- }, nil
-}
diff --git a/old/backend/internal/logic/member/list_members_logic.go b/old/backend/internal/logic/member/list_members_logic.go
deleted file mode 100644
index a0d7903..0000000
--- a/old/backend/internal/logic/member/list_members_logic.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/logic/authz"
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListMembersLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMembersLogic {
- return &ListMembersLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListMembersLogic) ListMembers(req *types.ListMembersReq) (resp *types.ListMembersData, err error) {
- if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- tenantID, _, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.Member.ListMembers(l.ctx, memberusecase.ListMembersRequest{
- TenantID: tenantID,
- Page: req.Page,
- PageSize: req.PageSize,
- })
- if err != nil {
- return nil, err
- }
- return &types.ListMembersData{
- Pagination: types.PaginationData{
- Total: result.Total,
- Page: result.Page,
- PageSize: result.PageSize,
- TotalPages: result.TotalPages,
- },
- List: toMemberMeDataListWithAvatar(result.List, l.svcCtx.Config.Storage.S3.Bucket),
- }, nil
-}
diff --git a/old/backend/internal/logic/member/mapper.go b/old/backend/internal/logic/member/mapper.go
deleted file mode 100644
index 88c78b7..0000000
--- a/old/backend/internal/logic/member/mapper.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/member/domain/entity"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- "haixun-backend/internal/types"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
-
-func toPlacementSettingsData(settings *placementusecase.Settings) types.MemberPlacementSettingsData {
- if settings == nil {
- return types.MemberPlacementSettingsData{}
- }
- return types.MemberPlacementSettingsData{
- WebSearchProvider: settings.WebSearchProvider,
- BraveAPIKey: settings.BraveAPIKey,
- BraveAPIKeyConfigured: settings.BraveAPIKeyConfigured,
- ExaAPIKey: settings.ExaAPIKey,
- ExaAPIKeyConfigured: settings.ExaAPIKeyConfigured,
- BraveCountry: settings.BraveCountry,
- BraveSearchLang: settings.BraveSearchLang,
- ExaUserLocation: settings.ExaUserLocation,
- ExpandStrategy: settings.ExpandStrategy,
- DevModeEnabled: settings.DevModeEnabled,
- }
-}
-
-func toMemberMeData(member *entity.Member) *types.MemberMeData {
- if member == nil {
- return nil
- }
- return &types.MemberMeData{
- TenantID: member.TenantID,
- UID: member.UID,
- Email: member.Email,
- EmailVerified: member.EmailVerified,
- DisplayName: member.DisplayName,
- Avatar: member.Avatar,
- Phone: member.Phone,
- Language: member.Language,
- Currency: member.Currency,
- Status: string(member.Status),
- Origin: string(member.Origin),
- Roles: member.Roles,
- BusinessEmail: member.BusinessEmail,
- BusinessEmailVerified: member.BusinessEmailVerified,
- BusinessPhone: member.BusinessPhone,
- BusinessPhoneVerified: member.BusinessPhoneVerified,
- CreateAt: member.CreateAt,
- UpdateAt: member.UpdateAt,
- }
-}
-
-func toMemberMeDataList(members []*entity.Member) []types.MemberMeData {
- list := make([]types.MemberMeData, 0, len(members))
- for _, item := range members {
- data := toMemberMeData(item)
- if data == nil {
- continue
- }
- list = append(list, *data)
- }
- return list
-}
diff --git a/old/backend/internal/logic/member/update_member_ai_settings_logic.go b/old/backend/internal/logic/member/update_member_ai_settings_logic.go
deleted file mode 100644
index 37f106d..0000000
--- a/old/backend/internal/logic/member/update_member_ai_settings_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateMemberAiSettingsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateMemberAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberAiSettingsLogic {
- return &UpdateMemberAiSettingsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateMemberAiSettingsLogic) UpdateMemberAiSettings(req *types.UpdateMemberAiSettingsReq) (resp *types.MemberAiSettingsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- data, err := l.svcCtx.ThreadsAccount.UpdateMemberAiSettings(l.ctx, tenantID, uid, toMemberAiSettingsPatch(req))
- if err != nil {
- return nil, err
- }
- return toMemberAiSettingsData(data), nil
-}
diff --git a/old/backend/internal/logic/member/update_member_me_logic.go b/old/backend/internal/logic/member/update_member_me_logic.go
deleted file mode 100644
index c8fd889..0000000
--- a/old/backend/internal/logic/member/update_member_me_logic.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type UpdateMemberMeLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateMemberMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberMeLogic {
- return &UpdateMemberMeLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdateMemberMeLogic) UpdateMemberMe(req *types.UpdateMemberMeReq) (*types.MemberMeData, error) {
- actor, ok := authctx.ActorFromContext(l.ctx)
- if !ok {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- bucket := l.svcCtx.Config.Storage.S3.Bucket
- member, err := l.svcCtx.Member.UpdateProfile(l.ctx, memberusecase.UpdateProfileRequest{
- TenantID: actor.TenantID,
- UID: actor.UID,
- DisplayName: optionalString(req.DisplayName),
- Avatar: optionalNormalizedAvatar(bucket, req.Avatar),
- Language: optionalString(req.Language),
- Currency: optionalString(req.Currency),
- Phone: optionalString(req.Phone),
- })
- if err != nil {
- return nil, err
- }
- return toMemberMeDataWithAvatar(member, bucket), nil
-}
-
-func optionalString(value string) *string {
- if value == "" {
- return nil
- }
- return &value
-}
diff --git a/old/backend/internal/logic/member/update_member_password_logic.go b/old/backend/internal/logic/member/update_member_password_logic.go
deleted file mode 100644
index 45bbab8..0000000
--- a/old/backend/internal/logic/member/update_member_password_logic.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/logic/authz"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateMemberPasswordLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateMemberPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberPasswordLogic {
- return &UpdateMemberPasswordLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateMemberPasswordLogic) UpdateMemberPassword(req *types.UpdateMemberPasswordReq) (resp *types.MemberMeData, err error) {
- if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- tenantID, _, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if err := l.svcCtx.Member.UpdatePassword(l.ctx, tenantID, req.UID, req.Password); err != nil {
- return nil, err
- }
- member, err := l.svcCtx.Member.GetByUID(l.ctx, tenantID, req.UID)
- if err != nil {
- return nil, err
- }
- return toMemberMeData(member), nil
-}
diff --git a/old/backend/internal/logic/member/update_member_placement_settings_logic.go b/old/backend/internal/logic/member/update_member_placement_settings_logic.go
deleted file mode 100644
index a0defad..0000000
--- a/old/backend/internal/logic/member/update_member_placement_settings_logic.go
+++ /dev/null
@@ -1,50 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- placementusecase "haixun-backend/internal/model/placement/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateMemberPlacementSettingsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateMemberPlacementSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberPlacementSettingsLogic {
- return &UpdateMemberPlacementSettingsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateMemberPlacementSettingsLogic) UpdateMemberPlacementSettings(req *types.UpdateMemberPlacementSettingsReq) (resp *types.MemberPlacementSettingsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- settings, err := l.svcCtx.Placement.Update(l.ctx, tenantID, uid, placementusecase.SettingsPatch{
- WebSearchProvider: req.WebSearchProvider,
- BraveAPIKey: req.BraveAPIKey,
- ExaAPIKey: req.ExaAPIKey,
- BraveCountry: req.BraveCountry,
- BraveSearchLang: req.BraveSearchLang,
- ExaUserLocation: req.ExaUserLocation,
- ExpandStrategy: req.ExpandStrategy,
- DevModeEnabled: req.DevModeEnabled,
- })
- if err != nil {
- return nil, err
- }
- data := toPlacementSettingsData(settings)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/member/update_member_profile_logic.go b/old/backend/internal/logic/member/update_member_profile_logic.go
deleted file mode 100644
index c3befaf..0000000
--- a/old/backend/internal/logic/member/update_member_profile_logic.go
+++ /dev/null
@@ -1,59 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- "haixun-backend/internal/logic/authz"
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateMemberProfileLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateMemberProfileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberProfileLogic {
- return &UpdateMemberProfileLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateMemberProfileLogic) UpdateMemberProfile(req *types.UpdateMemberProfileReq) (resp *types.MemberMeData, err error) {
- if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- tenantID, _, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- bucket := l.svcCtx.Config.Storage.S3.Bucket
- member, err := l.svcCtx.Member.UpdateProfile(l.ctx, memberusecase.UpdateProfileRequest{
- TenantID: tenantID,
- UID: req.UID,
- DisplayName: req.DisplayName,
- Avatar: optionalNormalizedAvatarPtr(bucket, req.Avatar),
- Language: req.Language,
- Currency: req.Currency,
- Phone: req.Phone,
- EmailVerified: req.EmailVerified,
- Status: req.Status,
- BusinessEmail: req.BusinessEmail,
- BusinessEmailVerified: req.BusinessEmailVerified,
- BusinessPhone: req.BusinessPhone,
- BusinessPhoneVerified: req.BusinessPhoneVerified,
- })
- if err != nil {
- return nil, err
- }
- return toMemberMeDataWithAvatar(member, bucket), nil
-}
diff --git a/old/backend/internal/logic/member/update_member_roles_logic.go b/old/backend/internal/logic/member/update_member_roles_logic.go
deleted file mode 100644
index ccb9f9d..0000000
--- a/old/backend/internal/logic/member/update_member_roles_logic.go
+++ /dev/null
@@ -1,51 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/logic/authz"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateMemberRolesLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateMemberRolesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberRolesLogic {
- return &UpdateMemberRolesLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateMemberRolesLogic) UpdateMemberRoles(req *types.UpdateMemberRolesReq) (resp *types.MemberMeData, err error) {
- if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- tenantID, actorUID, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if req.UID == actorUID {
- return nil, app.For(code.Auth).AuthForbidden("admin cannot change own roles")
- }
- if err := l.svcCtx.Member.SetRoles(l.ctx, tenantID, req.UID, req.Roles); err != nil {
- return nil, err
- }
- member, err := l.svcCtx.Member.GetByUID(l.ctx, tenantID, req.UID)
- if err != nil {
- return nil, err
- }
- return toMemberMeData(member), nil
-}
diff --git a/old/backend/internal/logic/member/upload_member_avatar_logic.go b/old/backend/internal/logic/member/upload_member_avatar_logic.go
deleted file mode 100644
index dcd70cc..0000000
--- a/old/backend/internal/logic/member/upload_member_avatar_logic.go
+++ /dev/null
@@ -1,68 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package member
-
-import (
- "context"
- "io"
- "path/filepath"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- memberusecase "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UploadMemberAvatarLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUploadMemberAvatarLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadMemberAvatarLogic {
- return &UploadMemberAvatarLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UploadMemberAvatarLogic) UploadMemberAvatar(filename, contentType string, body io.Reader) (resp *types.UploadAvatarData, err error) {
- if l.svcCtx.AvatarStorage == nil {
- return nil, app.For(code.Member).SysNotImplemented("avatar storage is not configured")
- }
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- ext := strings.ToLower(filepath.Ext(filename))
- if !allowedAvatarExt(ext) {
- return nil, app.For(code.Member).InputInvalidFormat("avatar file must be jpg, png, webp, or gif")
- }
- if contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "image/") {
- return nil, app.For(code.Member).InputInvalidFormat("avatar file must be an image")
- }
- key := "avatars/" + tenantID + "/" + uid + ext
- storedKey, err := l.svcCtx.AvatarStorage.Upload(l.ctx, key, contentType, body)
- if err != nil {
- return nil, app.For(code.Member).SysInternal("upload avatar failed").WithCause(err)
- }
- if _, err := l.svcCtx.Member.UpdateProfile(l.ctx, memberusecase.UpdateProfileRequest{TenantID: tenantID, UID: uid, Avatar: &storedKey}); err != nil {
- return nil, err
- }
- return &types.UploadAvatarData{Avatar: storedKey}, nil
-}
-
-func allowedAvatarExt(ext string) bool {
- switch ext {
- case ".jpg", ".jpeg", ".png", ".webp", ".gif":
- return true
- default:
- return false
- }
-}
diff --git a/old/backend/internal/logic/normal/health_logic.go b/old/backend/internal/logic/normal/health_logic.go
deleted file mode 100644
index 011a700..0000000
--- a/old/backend/internal/logic/normal/health_logic.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package normal
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type HealthLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HealthLogic {
- return &HealthLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *HealthLogic) Health() (*types.HealthData, error) {
- return &types.HealthData{Pong: "ok"}, nil
-}
diff --git a/old/backend/internal/logic/permission/get_me_permissions_logic.go b/old/backend/internal/logic/permission/get_me_permissions_logic.go
deleted file mode 100644
index 43f26e7..0000000
--- a/old/backend/internal/logic/permission/get_me_permissions_logic.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package permission
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetMePermissionsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetMePermissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMePermissionsLogic {
- return &GetMePermissionsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetMePermissionsLogic) GetMePermissions(req *types.MePermissionsQuery) (*types.MePermissionsData, error) {
- actor, ok := authctx.ActorFromContext(l.ctx)
- if !ok {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- member, err := l.svcCtx.Member.GetByUID(l.ctx, actor.TenantID, actor.UID)
- if err != nil {
- return nil, err
- }
- perms, err := l.svcCtx.Permission.Me(l.ctx, member, req.IncludeTree)
- if err != nil {
- return nil, err
- }
- return &types.MePermissionsData{
- UID: perms.UID,
- TenantID: perms.TenantID,
- Roles: perms.Roles,
- Permissions: perms.Permissions,
- Tree: toPermissionNodes(perms.Tree),
- }, nil
-}
diff --git a/old/backend/internal/logic/permission/get_permission_catalog_logic.go b/old/backend/internal/logic/permission/get_permission_catalog_logic.go
deleted file mode 100644
index 0a1fd60..0000000
--- a/old/backend/internal/logic/permission/get_permission_catalog_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package permission
-
-import (
- "context"
-
- "haixun-backend/internal/logic/authz"
- domusecase "haixun-backend/internal/model/permission/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetPermissionCatalogLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPermissionCatalogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPermissionCatalogLogic {
- return &GetPermissionCatalogLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetPermissionCatalogLogic) GetPermissionCatalog(req *types.PermissionCatalogQuery) (*types.PermissionCatalogData, error) {
- if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
- return nil, err
- }
- tree, list, err := l.svcCtx.Permission.Catalog(l.ctx, domusecase.CatalogRequest{
- Status: req.Status,
- Type: req.Type,
- Tree: req.Tree,
- })
- if err != nil {
- return nil, err
- }
- return &types.PermissionCatalogData{
- Tree: toPermissionNodes(tree),
- List: toPermissionNodes(list),
- }, nil
-}
diff --git a/old/backend/internal/logic/permission/mapper.go b/old/backend/internal/logic/permission/mapper.go
deleted file mode 100644
index 25f3b3f..0000000
--- a/old/backend/internal/logic/permission/mapper.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package permission
-
-import (
- domusecase "haixun-backend/internal/model/permission/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toPermissionNode(node domusecase.PermissionNode) types.PermissionNode {
- out := types.PermissionNode{
- ID: node.ID,
- Parent: node.Parent,
- Name: node.Name,
- HTTPMethods: node.HTTPMethods,
- HTTPPath: node.HTTPPath,
- Status: node.Status,
- Type: node.Type,
- }
- if len(node.Children) > 0 {
- out.Children = make([]types.PermissionNode, 0, len(node.Children))
- for _, child := range node.Children {
- out.Children = append(out.Children, toPermissionNode(child))
- }
- }
- return out
-}
-
-func toPermissionNodes(nodes []domusecase.PermissionNode) []types.PermissionNode {
- out := make([]types.PermissionNode, 0, len(nodes))
- for _, node := range nodes {
- out = append(out, toPermissionNode(node))
- }
- return out
-}
diff --git a/old/backend/internal/logic/persona/actor.go b/old/backend/internal/logic/persona/actor.go
deleted file mode 100644
index 9759156..0000000
--- a/old/backend/internal/logic/persona/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/old/backend/internal/logic/persona/content_ops_mapper.go b/old/backend/internal/logic/persona/content_ops_mapper.go
deleted file mode 100644
index 595c460..0000000
--- a/old/backend/internal/logic/persona/content_ops_mapper.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package persona
-
-import (
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toTopicCandidateData(item contentops.TopicCandidateSummary) types.TopicCandidateData {
- return types.TopicCandidateData{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
-}
-
-func toContentPlanData(item contentops.ContentPlanSummary) types.ContentPlanData {
- return types.ContentPlanData{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
-}
-
-func toFeedbackEventData(item contentops.FeedbackEventSummary) types.FeedbackEventData {
- return types.FeedbackEventData{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt}
-}
-
-func toKnowledgeSourceData(item contentops.KnowledgeSourceSummary) types.KnowledgeSourceData {
- return types.KnowledgeSourceData{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt}
-}
-
-func toKnowledgeChunkData(item contentops.KnowledgeChunkSummary) types.KnowledgeChunkData {
- return types.KnowledgeChunkData{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt}
-}
-
-func toFormulaPoolData(item contentops.FormulaPoolSummary) types.FormulaPoolData {
- return types.FormulaPoolData{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
-}
diff --git a/old/backend/internal/logic/persona/copy_draft_mapper.go b/old/backend/internal/logic/persona/copy_draft_mapper.go
deleted file mode 100644
index adf9610..0000000
--- a/old/backend/internal/logic/persona/copy_draft_mapper.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package persona
-
-import (
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toCopyDraftData(item copydraftdomain.CopyDraftSummary) types.CopyDraftData {
- return types.CopyDraftData{
- ID: item.ID,
- PersonaID: item.PersonaID,
- ContentPlanID: item.ContentPlanID,
- CopyMissionID: item.CopyMissionID,
- ScanPostID: item.ScanPostID,
- FormulaID: item.FormulaID,
- DraftType: item.DraftType,
- SortOrder: item.SortOrder,
- Text: item.Text,
- TopicTag: item.TopicTag,
- Angle: item.Angle,
- Hook: item.Hook,
- Rationale: item.Rationale,
- ReferenceNotes: item.ReferenceNotes,
- Sources: item.Sources,
- AiScore: item.AiScore,
- FormulaScore: item.FormulaScore,
- BrandFitScore: item.BrandFitScore,
- RiskScore: item.RiskScore,
- SimilarityScore: item.SimilarityScore,
- EngagementPotential: item.EngagementPotential,
- FreshnessScore: item.FreshnessScore,
- ReviewSuggestion: item.ReviewSuggestion,
- Status: item.Status,
- PublishQueueID: item.PublishQueueID,
- PublishedMediaID: item.PublishedMediaID,
- PublishedPermalink: item.PublishedPermalink,
- PublishedAt: item.PublishedAt,
- CreateAt: item.CreateAt,
- }
-}
diff --git a/old/backend/internal/logic/persona/create_content_plan_logic.go b/old/backend/internal/logic/persona/create_content_plan_logic.go
deleted file mode 100644
index b00ddde..0000000
--- a/old/backend/internal/logic/persona/create_content_plan_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateContentPlanLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateContentPlanLogic {
- return &CreateContentPlanLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreateContentPlanLogic) CreateContentPlan(req *types.CreateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentOps.CreateContentPlan(l.ctx, contentops.ContentPlanInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, TopicCandidateID: req.TopicCandidateID, Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge})
- if err != nil {
- return nil, err
- }
- data := toContentPlanData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/persona/create_feedback_event_logic.go b/old/backend/internal/logic/persona/create_feedback_event_logic.go
deleted file mode 100644
index aa1870e..0000000
--- a/old/backend/internal/logic/persona/create_feedback_event_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateFeedbackEventLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateFeedbackEventLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFeedbackEventLogic {
- return &CreateFeedbackEventLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreateFeedbackEventLogic) CreateFeedbackEvent(req *types.CreateFeedbackEventHandlerReq) (resp *types.FeedbackEventData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentOps.CreateFeedbackEvent(l.ctx, contentops.FeedbackEventInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, ContentPlanID: req.ContentPlanID, DraftID: req.DraftID, Decision: req.Decision, Note: req.Note, Snapshot: req.Snapshot})
- if err != nil {
- return nil, err
- }
- data := toFeedbackEventData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/persona/create_formula_pool_logic.go b/old/backend/internal/logic/persona/create_formula_pool_logic.go
deleted file mode 100644
index b769f8c..0000000
--- a/old/backend/internal/logic/persona/create_formula_pool_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateFormulaPoolLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateFormulaPoolLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateFormulaPoolLogic {
- return &CreateFormulaPoolLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreateFormulaPoolLogic) CreateFormulaPool(req *types.CreateFormulaPoolHandlerReq) (resp *types.FormulaPoolData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentOps.CreateFormulaPool(l.ctx, contentops.FormulaPoolInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, Type: req.Type, Name: req.Name, Pattern: req.Pattern, UseCases: req.UseCases, Avoid: req.Avoid, Weight: req.Weight})
- if err != nil {
- return nil, err
- }
- data := toFormulaPoolData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/persona/create_knowledge_source_logic.go b/old/backend/internal/logic/persona/create_knowledge_source_logic.go
deleted file mode 100644
index 4e83e74..0000000
--- a/old/backend/internal/logic/persona/create_knowledge_source_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreateKnowledgeSourceLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateKnowledgeSourceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateKnowledgeSourceLogic {
- return &CreateKnowledgeSourceLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreateKnowledgeSourceLogic) CreateKnowledgeSource(req *types.CreateKnowledgeSourceHandlerReq) (resp *types.KnowledgeSourceData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentOps.CreateKnowledgeSource(l.ctx, contentops.KnowledgeSourceInput{TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, SourceType: req.SourceType, Title: req.Title, Filename: req.Filename, Content: req.Content, ContentBase64: req.ContentBase64})
- if err != nil {
- return nil, err
- }
- data := toKnowledgeSourceData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/persona/create_persona_logic.go b/old/backend/internal/logic/persona/create_persona_logic.go
deleted file mode 100644
index 1953664..0000000
--- a/old/backend/internal/logic/persona/create_persona_logic.go
+++ /dev/null
@@ -1,49 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/persona/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreatePersonaLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreatePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePersonaLogic {
- return &CreatePersonaLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *CreatePersonaLogic) CreatePersona(req *types.CreatePersonaReq) (resp *types.PersonaData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- displayName := ""
- if req != nil {
- displayName = req.DisplayName
- }
- item, err := l.svcCtx.Persona.Create(l.ctx, domusecase.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- DisplayName: displayName,
- })
- if err != nil {
- return nil, err
- }
- out := toPersonaData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/persona/delete_persona_copy_draft_logic.go b/old/backend/internal/logic/persona/delete_persona_copy_draft_logic.go
deleted file mode 100644
index f4298f7..0000000
--- a/old/backend/internal/logic/persona/delete_persona_copy_draft_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DeletePersonaCopyDraftLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeletePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePersonaCopyDraftLogic {
- return &DeletePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeletePersonaCopyDraftLogic) DeletePersonaCopyDraft(req *types.CopyDraftPath) (*types.DeleteCopyDraftData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- draftID := strings.TrimSpace(req.DraftID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- if err := l.svcCtx.CopyDraft.Delete(l.ctx, tenantID, uid, personaID, draftID); err != nil {
- return nil, err
- }
- return &types.DeleteCopyDraftData{
- DraftID: draftID,
- Message: "草稿已刪除",
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/delete_persona_logic.go b/old/backend/internal/logic/persona/delete_persona_logic.go
deleted file mode 100644
index a5f6b57..0000000
--- a/old/backend/internal/logic/persona/delete_persona_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeletePersonaLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeletePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePersonaLogic {
- return &DeletePersonaLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *DeletePersonaLogic) DeletePersona(req *types.PersonaPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.Persona.Delete(l.ctx, tenantID, uid, req.ID)
-}
diff --git a/old/backend/internal/logic/persona/delete_style_preset_logic.go b/old/backend/internal/logic/persona/delete_style_preset_logic.go
deleted file mode 100644
index 0327d5b..0000000
--- a/old/backend/internal/logic/persona/delete_style_preset_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeleteStylePresetLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteStylePresetLogic {
- return &DeleteStylePresetLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *DeleteStylePresetLogic) DeleteStylePreset(req *types.StylePresetPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.StylePreset.Delete(l.ctx, tenantID, uid, req.ID, req.PresetID)
-}
diff --git a/old/backend/internal/logic/persona/generate_from_content_formula_logic.go b/old/backend/internal/logic/persona/generate_from_content_formula_logic.go
deleted file mode 100644
index f0f3bb1..0000000
--- a/old/backend/internal/logic/persona/generate_from_content_formula_logic.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libformula "haixun-backend/internal/library/formula"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/style8d"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- copydraftentity "haixun-backend/internal/model/copy_draft/domain/entity"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GenerateFromContentFormulaLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGenerateFromContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateFromContentFormulaLogic {
- return &GenerateFromContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GenerateFromContentFormulaLogic) GenerateFromContentFormula(req *types.GenerateFromContentFormulaHandlerReq) (*types.GenerateFromContentFormulaData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- topic := strings.TrimSpace(req.Topic)
- if topic == "" {
- return nil, app.For(code.Persona).InputMissingRequired("topic is required")
- }
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- formula, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.AccountID, req.FormulaID)
- if err != nil {
- return nil, err
- }
- count := req.DraftCount
- if count <= 0 {
- count = 1
- }
- if count > 5 {
- count = 5
- }
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
- aiReq := domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{APIKey: credential.APIKey},
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- researchNotes := ""
- if req.UseWebSearch {
- research, rerr := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if rerr == nil && placement.WebSearchAvailable(research) {
- memberCtx, merr := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if merr == nil {
- webClient := websearch.New(memberCtx.WebSearchConfig())
- if webClient.Enabled() {
- resp, _ := webClient.Search(l.ctx, websearch.SearchOptions{
- Query: topic + " " + strings.TrimSpace(req.Brief),
- Limit: 5,
- Mode: websearch.ModeKnowledgeExpand,
- })
- if len(resp.Results) > 0 {
- var b strings.Builder
- for _, snip := range resp.Results {
- if snip.Title != "" {
- b.WriteString("- ")
- b.WriteString(snip.Title)
- b.WriteString("\n")
- }
- if snip.Snippet != "" {
- b.WriteString(snip.Snippet)
- b.WriteString("\n")
- }
- }
- researchNotes = strings.TrimSpace(b.String())
- }
- }
- }
- }
- }
-
- list := make([]types.CopyDraftData, 0, count)
- for i := 0; i < count; i++ {
- generated, genErr := libformula.GenerateDraft(l.ctx, l.svcCtx.AI, aiReq, libformula.GenerateInput{
- Topic: topic,
- Brief: req.Brief,
- PersonaBlock: personaBlock,
- ResearchNotes: researchNotes,
- Formula: *formula,
- })
- if genErr != nil {
- return nil, app.For(code.AI).SvcThirdParty("貼文生成失敗:" + genErr.Error())
- }
- saved, saveErr := l.svcCtx.CopyDraft.Create(l.ctx, copydraftdomain.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- FormulaID: formula.ID,
- DraftType: copydraftentity.DraftTypeFormula,
- Text: generated.Text,
- TopicTag: generated.TopicTag,
- Angle: generated.Angle,
- Hook: generated.Hook,
- Rationale: generated.Rationale,
- ReferenceNotes: generated.StructureNotes,
- Sources: []string{formula.Label},
- })
- if saveErr != nil {
- return nil, saveErr
- }
- list = append(list, toCopyDraftData(*saved))
- }
- return &types.GenerateFromContentFormulaData{
- List: list,
- Message: fmt.Sprintf("已產生 %d 篇可編輯草稿,已加入排程區", len(list)),
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/generate_persona_copy_draft_logic.go b/old/backend/internal/logic/persona/generate_persona_copy_draft_logic.go
deleted file mode 100644
index 389d017..0000000
--- a/old/backend/internal/logic/persona/generate_persona_copy_draft_logic.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/style8d"
- libviral "haixun-backend/internal/library/viral"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- copydraftusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GeneratePersonaCopyDraftLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGeneratePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePersonaCopyDraftLogic {
- return &GeneratePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GeneratePersonaCopyDraftLogic) GeneratePersonaCopyDraft(
- req *types.GeneratePersonaCopyDraftHandlerReq,
-) (*types.GeneratePersonaCopyDraftData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- scanPostID := strings.TrimSpace(req.ScanPostID)
- if scanPostID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("scan_post_id is required")
- }
-
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- post, err := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
- if err != nil {
- return nil, err
- }
-
- topicLabel := strings.TrimSpace(post.SearchTag)
- topicBrief := strings.TrimSpace(persona.Brief)
- if missionID := strings.TrimSpace(post.CopyMissionID); missionID != "" {
- if mission, missionErr := l.svcCtx.CopyMission.Get(l.ctx, tenantID, uid, personaID, missionID); missionErr == nil {
- if label := strings.TrimSpace(mission.Label); label != "" {
- topicLabel = label
- }
- if brief := strings.TrimSpace(mission.Brief); brief != "" {
- topicBrief = brief
- }
- }
- }
-
- if !style8d.HasReady8D(persona.Persona, persona.StyleProfile) {
- return nil, app.For(code.Persona).InputMissingRequired("請先完成人設 8D 對標分析")
- }
- personaBlock := style8d.ResolvePersonaBlock(persona.Persona, persona.StyleProfile, persona.Brief)
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- analysisText := ""
- analyzeResult, analyzeErr := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: libviral.BuildAnalyzeViralSystemPrompt(),
- Messages: []domai.Message{
- {
- Role: "user",
- Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{
- PostText: post.Text,
- AuthorName: post.Author,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- SearchTag: post.SearchTag,
- TopicLabel: topicLabel,
- TopicBrief: topicBrief,
- Persona: personaBlock,
- }),
- },
- },
- })
- if analyzeErr == nil {
- if parsed, parseErr := libviral.ParseAnalyzeViralOutput(analyzeResult.Text); parseErr == nil {
- analysisText = libviral.FormatAnalysisForReplicate(parsed)
- }
- }
-
- replicateTemp := 0.82
- result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: libviral.BuildSystemPrompt(),
- Temperature: &replicateTemp,
- Messages: []domai.Message{
- {
- Role: "user",
- Content: libviral.BuildUserPrompt(libviral.ReplicateInput{
- TopicLabel: topicLabel,
- TopicBrief: topicBrief,
- Persona: personaBlock,
- StyleProfile: "",
- OriginalText: post.Text,
- AuthorName: post.Author,
- StructureAnalysis: analysisText,
- NarrativeSeed: scanPostID,
- }),
- },
- },
- })
- if err != nil {
- return nil, err
- }
-
- parsed, err := libviral.ParseReplicateOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("仿寫 LLM 回傳無法解析:" + err.Error())
- }
-
- saved, err := l.svcCtx.CopyDraft.Create(l.ctx, copydraftusecase.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- CopyMissionID: post.CopyMissionID,
- ScanPostID: scanPostID,
- DraftType: "replicate",
- Text: parsed.Text,
- Angle: parsed.Angle,
- Hook: parsed.Hook,
- Rationale: parsed.Rationale,
- ReferenceNotes: parsed.StructureNotes,
- Sources: []string{post.Permalink},
- })
- if err != nil {
- return nil, err
- }
-
- return &types.GeneratePersonaCopyDraftData{
- Draft: toCopyDraftData(*saved),
- Message: "已產出仿寫草稿",
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go b/old/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go
deleted file mode 100644
index 7557b97..0000000
--- a/old/backend/internal/logic/persona/generate_persona_topic_matrix_logic.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libpersonacopy "haixun-backend/internal/library/personacopy"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GeneratePersonaTopicMatrixLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGeneratePersonaTopicMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePersonaTopicMatrixLogic {
- return &GeneratePersonaTopicMatrixLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GeneratePersonaTopicMatrixLogic) GeneratePersonaTopicMatrix(req *types.GeneratePersonaTopicMatrixHandlerReq) (*types.GeneratePersonaTopicMatrixData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- topic := strings.TrimSpace(req.Topic)
- if topic == "" {
- return nil, app.For(code.Persona).InputMissingRequired("topic is required")
- }
-
- created, err := libpersonacopy.RunTopicMatrix(l.ctx, libpersonacopy.TopicMatrixDeps{
- Persona: l.svcCtx.Persona,
- CopyDraft: l.svcCtx.CopyDraft,
- ContentOps: l.svcCtx.ContentOps,
- ThreadsAccount: l.svcCtx.ThreadsAccount,
- Placement: l.svcCtx.Placement,
- AI: l.svcCtx.AI,
- }, libpersonacopy.TopicMatrixInput{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- ContentPlanID: req.ContentPlanID,
- Topic: topic,
- Brief: req.Brief,
- UseWebSearch: req.UseWebSearch,
- DraftCount: req.DraftCount,
- }, nil)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("矩陣草稿生成失敗:" + err.Error())
- }
-
- list := make([]types.CopyDraftData, 0, len(created))
- for _, item := range created {
- list = append(list, toCopyDraftData(item))
- }
-
- return &types.GeneratePersonaTopicMatrixData{
- List: list,
- Message: fmt.Sprintf("已產生 %d 篇矩陣草稿", len(list)),
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/get_persona_logic.go b/old/backend/internal/logic/persona/get_persona_logic.go
deleted file mode 100644
index 1542708..0000000
--- a/old/backend/internal/logic/persona/get_persona_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-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.PersonaPath) (resp *types.PersonaData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- out := toPersonaData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/persona/list_content_plans_logic.go b/old/backend/internal/logic/persona/list_content_plans_logic.go
deleted file mode 100644
index 9fbe8fd..0000000
--- a/old/backend/internal/logic/persona/list_content_plans_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListContentPlansLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListContentPlansLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentPlansLogic {
- return &ListContentPlansLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListContentPlansLogic) ListContentPlans(req *types.PersonaPath) (resp *types.ListContentPlansData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.ContentOps.ListContentPlans(l.ctx, tenantID, uid, req.ID, 50)
- if err != nil {
- return nil, err
- }
- list := make([]types.ContentPlanData, 0, len(items))
- for _, item := range items {
- list = append(list, toContentPlanData(item))
- }
- return &types.ListContentPlansData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/persona/list_formula_pools_logic.go b/old/backend/internal/logic/persona/list_formula_pools_logic.go
deleted file mode 100644
index 6eabec9..0000000
--- a/old/backend/internal/logic/persona/list_formula_pools_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListFormulaPoolsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListFormulaPoolsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListFormulaPoolsLogic {
- return &ListFormulaPoolsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListFormulaPoolsLogic) ListFormulaPools(req *types.PersonaPath) (resp *types.ListFormulaPoolsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.ContentOps.ListFormulaPools(l.ctx, tenantID, uid, req.ID, 50)
- if err != nil {
- return nil, err
- }
- list := make([]types.FormulaPoolData, 0, len(items))
- for _, item := range items {
- list = append(list, toFormulaPoolData(item))
- }
- return &types.ListFormulaPoolsData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/persona/list_knowledge_chunks_logic.go b/old/backend/internal/logic/persona/list_knowledge_chunks_logic.go
deleted file mode 100644
index 110e052..0000000
--- a/old/backend/internal/logic/persona/list_knowledge_chunks_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListKnowledgeChunksLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListKnowledgeChunksLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeChunksLogic {
- return &ListKnowledgeChunksLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListKnowledgeChunksLogic) ListKnowledgeChunks(req *types.PersonaPath) (resp *types.ListKnowledgeChunksData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.ContentOps.ListKnowledgeChunks(l.ctx, tenantID, uid, req.ID, 80)
- if err != nil {
- return nil, err
- }
- list := make([]types.KnowledgeChunkData, 0, len(items))
- for _, item := range items {
- list = append(list, toKnowledgeChunkData(item))
- }
- return &types.ListKnowledgeChunksData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/persona/list_knowledge_sources_logic.go b/old/backend/internal/logic/persona/list_knowledge_sources_logic.go
deleted file mode 100644
index c2ba664..0000000
--- a/old/backend/internal/logic/persona/list_knowledge_sources_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListKnowledgeSourcesLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListKnowledgeSourcesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListKnowledgeSourcesLogic {
- return &ListKnowledgeSourcesLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListKnowledgeSourcesLogic) ListKnowledgeSources(req *types.PersonaPath) (resp *types.ListKnowledgeSourcesData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.ContentOps.ListKnowledgeSources(l.ctx, tenantID, uid, req.ID, 50)
- if err != nil {
- return nil, err
- }
- list := make([]types.KnowledgeSourceData, 0, len(items))
- for _, item := range items {
- list = append(list, toKnowledgeSourceData(item))
- }
- return &types.ListKnowledgeSourcesData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/persona/list_persona_content_inbox_logic.go b/old/backend/internal/logic/persona/list_persona_content_inbox_logic.go
deleted file mode 100644
index ffe2e9c..0000000
--- a/old/backend/internal/logic/persona/list_persona_content_inbox_logic.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package persona
-
-import (
- "context"
- "math"
-
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListPersonaContentInboxLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPersonaContentInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonaContentInboxLogic {
- return &ListPersonaContentInboxLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListPersonaContentInboxLogic) ListPersonaContentInbox(req *types.ListPersonaContentInboxHandlerReq) (*types.ListPersonaContentInboxData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := req.ID
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- page := int(req.Page)
- if page <= 0 {
- page = 1
- }
- pageSize := int(req.PageSize)
- if pageSize <= 0 {
- pageSize = 12
- }
- result, err := l.svcCtx.CopyDraft.ListInbox(l.ctx, copydraftdomain.InboxListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- Status: req.Status,
- RangeStart: req.RangeStart,
- RangeEnd: req.RangeEnd,
- Page: page,
- PageSize: pageSize,
- })
- if err != nil {
- return nil, err
- }
-
- queueIDs := make([]string, 0)
- for _, item := range result.Items {
- if item.PublishQueueID != "" {
- queueIDs = append(queueIDs, item.PublishQueueID)
- }
- }
- queueByID := map[string]pqdomain.QueueItemSummary{}
- if len(queueIDs) > 0 {
- queueItems, qerr := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, queueIDs)
- if qerr == nil {
- for _, q := range queueItems {
- queueByID[q.ID] = q
- }
- }
- }
-
- list := make([]types.ContentInboxItemData, 0, len(result.Items))
- for _, item := range result.Items {
- queueItem := queueByID[item.PublishQueueID]
- scheduledAt := queueItem.ScheduledAt
- lifecycle, groupDate := inboxLifecycle(item, scheduledAt)
- draftData := toCopyDraftData(item)
- if queueItem.TopicTag != "" {
- draftData.TopicTag = queueItem.TopicTag
- }
- list = append(list, types.ContentInboxItemData{
- CopyDraftData: draftData,
- ScheduledAt: scheduledAt,
- Lifecycle: lifecycle,
- GroupDate: groupDate,
- })
- }
-
- totalPages := int64(0)
- if pageSize > 0 {
- totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize)))
- }
- return &types.ListPersonaContentInboxData{
- Pagination: types.PaginationData{
- Total: result.Total,
- Page: int64(page),
- PageSize: int64(pageSize),
- TotalPages: totalPages,
- },
- List: list,
- }, nil
-}
-
-func inboxLifecycle(item copydraftdomain.CopyDraftSummary, scheduledAt int64) (lifecycle string, groupDate int64) {
- if item.PublishedAt > 0 {
- return "published", item.PublishedAt
- }
- if item.PublishQueueID != "" || scheduledAt > 0 {
- if scheduledAt > 0 {
- return "scheduled", scheduledAt
- }
- return "scheduled", item.CreateAt
- }
- return "draft", item.CreateAt
-}
diff --git a/old/backend/internal/logic/persona/list_persona_copy_drafts_logic.go b/old/backend/internal/logic/persona/list_persona_copy_drafts_logic.go
deleted file mode 100644
index c3f5042..0000000
--- a/old/backend/internal/logic/persona/list_persona_copy_drafts_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListPersonaCopyDraftsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPersonaCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonaCopyDraftsLogic {
- return &ListPersonaCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListPersonaCopyDraftsLogic) ListPersonaCopyDrafts(req *types.PersonaPath) (*types.ListPersonaCopyDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
-
- drafts, err := l.svcCtx.CopyDraft.List(l.ctx, tenantID, uid, personaID, 50)
- if err != nil {
- return nil, err
- }
- list := make([]types.CopyDraftData, 0, len(drafts))
- for _, item := range drafts {
- list = append(list, toCopyDraftData(item))
- }
- return &types.ListPersonaCopyDraftsData{List: list, Total: len(list)}, nil
-}
diff --git a/old/backend/internal/logic/persona/list_persona_viral_scan_posts_logic.go b/old/backend/internal/logic/persona/list_persona_viral_scan_posts_logic.go
deleted file mode 100644
index 8448035..0000000
--- a/old/backend/internal/logic/persona/list_persona_viral_scan_posts_logic.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
-
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListPersonaViralScanPostsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPersonaViralScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPersonaViralScanPostsLogic {
- return &ListPersonaViralScanPostsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListPersonaViralScanPostsLogic) ListPersonaViralScanPosts(
- req *types.ListPersonaViralScanPostsHandlerReq,
-) (*types.ListPersonaViralScanPostsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
-
- limit := 100
- if req.Limit > 0 {
- limit = req.Limit
- }
- posts, err := l.svcCtx.ScanPost.ListForPersona(l.ctx, scanpostusecase.PersonaListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- Limit: limit,
- })
- if err != nil {
- return nil, err
- }
-
- list := make([]types.ViralScanPostData, 0, len(posts))
- for _, post := range posts {
- list = append(list, types.ViralScanPostData{
- ID: post.ID,
- SearchTag: post.SearchTag,
- Permalink: post.Permalink,
- Author: post.Author,
- AuthorVerified: post.AuthorVerified,
- FollowerCount: post.FollowerCount,
- Text: post.Text,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- EngagementScore: post.EngagementScore,
- Source: post.Source,
- ScanJobID: post.ScanJobID,
- Replies: viralScanReplies(post.Replies),
- CreateAt: post.CreateAt,
- })
- }
- return &types.ListPersonaViralScanPostsData{List: list, Total: len(list)}, nil
-}
-
-func viralScanReplies(replies []scanpostusecase.ScanReplySummary) []types.ScanReplyData {
- if len(replies) == 0 {
- return nil
- }
- out := make([]types.ScanReplyData, 0, len(replies))
- for _, reply := range replies {
- out = append(out, types.ScanReplyData{
- ExternalID: reply.ExternalID,
- Author: reply.Author,
- Text: reply.Text,
- Permalink: reply.Permalink,
- LikeCount: reply.LikeCount,
- PostedAt: reply.PostedAt,
- })
- }
- return out
-}
diff --git a/old/backend/internal/logic/persona/list_personas_logic.go b/old/backend/internal/logic/persona/list_personas_logic.go
deleted file mode 100644
index 4adff29..0000000
--- a/old/backend/internal/logic/persona/list_personas_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-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() (resp *types.ListPersonasData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.Persona.List(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- return toListData(result), nil
-}
diff --git a/old/backend/internal/logic/persona/list_style_presets_logic.go b/old/backend/internal/logic/persona/list_style_presets_logic.go
deleted file mode 100644
index c190192..0000000
--- a/old/backend/internal/logic/persona/list_style_presets_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListStylePresetsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListStylePresetsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListStylePresetsLogic {
- return &ListStylePresetsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListStylePresetsLogic) ListStylePresets(req *types.PersonaPath) (resp *types.ListStylePresetsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.StylePreset.List(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- list := make([]types.StylePresetData, 0, len(items))
- for _, item := range items {
- list = append(list, toStylePresetData(item))
- }
- return &types.ListStylePresetsData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/persona/list_topic_candidates_logic.go b/old/backend/internal/logic/persona/list_topic_candidates_logic.go
deleted file mode 100644
index b670d0f..0000000
--- a/old/backend/internal/logic/persona/list_topic_candidates_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListTopicCandidatesLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListTopicCandidatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListTopicCandidatesLogic {
- return &ListTopicCandidatesLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListTopicCandidatesLogic) ListTopicCandidates(req *types.PersonaPath) (resp *types.ListTopicCandidatesData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.ContentOps.ListTopicCandidates(l.ctx, tenantID, uid, req.ID, 50)
- if err != nil {
- return nil, err
- }
- list := make([]types.TopicCandidateData, 0, len(items))
- for _, item := range items {
- list = append(list, toTopicCandidateData(item))
- }
- return &types.ListTopicCandidatesData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/persona/mapper.go b/old/backend/internal/logic/persona/mapper.go
deleted file mode 100644
index 3d3c6dc..0000000
--- a/old/backend/internal/logic/persona/mapper.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package persona
-
-import (
- domusecase "haixun-backend/internal/model/persona/domain/usecase"
- stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toPersonaData(item domusecase.PersonaSummary) types.PersonaData {
- return types.PersonaData{
- ID: item.ID,
- DisplayName: item.DisplayName,
- Persona: item.Persona,
- Brief: item.Brief,
- StyleProfile: item.StyleProfile,
- StyleBenchmark: item.StyleBenchmark,
- SeedQuery: item.SeedQuery,
- CopyResearchMap: toCopyResearchMapData(item.CopyResearchMap),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toCopyResearchMapData(item domusecase.CopyResearchMapSummary) types.CopyResearchMapData {
- return types.CopyResearchMapData{
- AudienceSummary: item.AudienceSummary,
- ContentGoal: item.ContentGoal,
- Questions: item.Questions,
- Pillars: item.Pillars,
- Exclusions: item.Exclusions,
- SuggestedTags: item.SuggestedTags,
- BenchmarkNotes: item.BenchmarkNotes,
- }
-}
-
-func toListData(result *domusecase.ListResult) *types.ListPersonasData {
- if result == nil {
- return &types.ListPersonasData{List: []types.PersonaData{}}
- }
- list := make([]types.PersonaData, 0, len(result.List))
- for _, item := range result.List {
- list = append(list, toPersonaData(item))
- }
- return &types.ListPersonasData{List: list}
-}
-
-func toPersonaPatch(req *types.UpdatePersonaReq) domusecase.PersonaPatch {
- if req == nil {
- return domusecase.PersonaPatch{}
- }
- return domusecase.PersonaPatch{
- DisplayName: req.DisplayName,
- Persona: req.Persona,
- Brief: req.Brief,
- StyleProfile: req.StyleProfile,
- StyleBenchmark: req.StyleBenchmark,
- }
-}
-
-func toStylePresetData(item stylepresetdomain.PresetSummary) types.StylePresetData {
- return types.StylePresetData{
- ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone,
- CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...),
- Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
- }
-}
diff --git a/old/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go b/old/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go
deleted file mode 100644
index cfdc3c4..0000000
--- a/old/backend/internal/logic/persona/prune_persona_copy_drafts_logic.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type PrunePersonaCopyDraftsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPrunePersonaCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PrunePersonaCopyDraftsLogic {
- return &PrunePersonaCopyDraftsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PrunePersonaCopyDraftsLogic) PrunePersonaCopyDrafts(req *types.PrunePersonaCopyDraftsHandlerReq) (*types.PrunePersonaCopyDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := req.ID
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- keep := int(req.Keep)
- if keep <= 0 {
- keep = 40
- }
- deleted, err := l.svcCtx.CopyDraft.PruneIdleDrafts(l.ctx, tenantID, uid, personaID, keep)
- if err != nil {
- return nil, err
- }
- message := fmt.Sprintf("已保留最近 %d 篇草稿", keep)
- if deleted > 0 {
- message = fmt.Sprintf("已刪除 %d 篇多餘舊草稿,保留最近 %d 篇", deleted, keep)
- }
- return &types.PrunePersonaCopyDraftsData{
- Deleted: deleted,
- Kept: keep,
- Message: message,
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/publish_persona_copy_draft_logic.go b/old/backend/internal/logic/persona/publish_persona_copy_draft_logic.go
deleted file mode 100644
index 78d08d6..0000000
--- a/old/backend/internal/logic/persona/publish_persona_copy_draft_logic.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/threadspost"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type PublishPersonaCopyDraftLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPublishPersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishPersonaCopyDraftLogic {
- return &PublishPersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PublishPersonaCopyDraftLogic) PublishPersonaCopyDraft(
- req *types.PublishCopyDraftHandlerReq,
-) (*types.PublishCopyDraftData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- draftID := strings.TrimSpace(req.DraftID)
- if !req.Confirm {
- return nil, app.For(code.Persona).InputMissingRequired("請確認 confirm=true 後再發布貼文")
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
-
- draftItem, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, personaID, draftID)
- if err != nil {
- return nil, err
- }
- draft := draftItem
- if draft.Status == "published" {
- return nil, app.For(code.Persona).ResInvalidState("此草稿已發布")
- }
-
- text := strings.TrimSpace(req.Text)
- if text == "" {
- text = strings.TrimSpace(draft.Text)
- }
- if text == "" {
- return nil, app.For(code.Persona).InputMissingRequired("text is required")
- }
- if err := threadspost.ValidatePublish(text); err != nil {
- return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
- }
- topicTag := firstNonEmpty(req.TopicTag, draft.TopicTag)
- patch := copydraftdomain.CopyDraftPatch{}
- needsUpdate := false
- if strings.TrimSpace(req.Text) != "" && req.Text != draft.Text {
- patch.Text = &req.Text
- needsUpdate = true
- }
- if strings.TrimSpace(req.TopicTag) != "" && req.TopicTag != draft.TopicTag {
- patch.TopicTag = &req.TopicTag
- needsUpdate = true
- }
- if needsUpdate {
- updated, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- DraftID: draftID,
- Patch: patch,
- })
- if err != nil {
- return nil, err
- }
- draft = updated
- text = strings.TrimSpace(updated.Text)
- topicTag = firstNonEmpty(updated.TopicTag, req.TopicTag)
- }
-
- cred, err := l.svcCtx.ThreadsAccount.ResolveMemberThreadsPublishCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- result, err := libthreads.PublishText(l.ctx, libthreads.PublishTextInput{
- ThreadsUserID: cred.ThreadsUserID,
- AccessToken: cred.AccessToken,
- Text: text,
- TopicTag: topicTag,
- })
- if err != nil {
- return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads API 發布貼文失敗:" + err.Error())
- }
-
- now := time.Now().UnixNano()
- if cred.AccountID != "" {
- _, _ = l.svcCtx.PublishQueue.RecordPublished(l.ctx, publishqueuedomain.RecordPublishedRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: cred.AccountID,
- Text: text,
- TopicTag: topicTag,
- MediaID: result.MediaID,
- Permalink: result.Permalink,
- PublishedAt: now,
- })
- }
-
- updated, err := l.svcCtx.CopyDraft.MarkPublished(l.ctx, copydraftdomain.MarkPublishedRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- DraftID: draftID,
- MediaID: result.MediaID,
- Permalink: result.Permalink,
- })
- if err != nil {
- return nil, err
- }
-
- return &types.PublishCopyDraftData{
- DraftID: draftID,
- MediaID: result.MediaID,
- Permalink: result.Permalink,
- Status: updated.Status,
- Message: "仿寫貼文已透過 Threads API 發布",
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go b/old/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go
deleted file mode 100644
index 55cdfca..0000000
--- a/old/backend/internal/logic/persona/schedule_persona_copy_draft_logic.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
-
- "haixun-backend/internal/library/clock"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type SchedulePersonaCopyDraftLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewSchedulePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaCopyDraftLogic {
- return &SchedulePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *SchedulePersonaCopyDraftLogic) SchedulePersonaCopyDraft(req *types.SchedulePersonaCopyDraftHandlerReq) (*types.ScheduleCopyDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
- return nil, err
- }
- draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, req.DraftID)
- if err != nil {
- return nil, err
- }
- if draft.PublishQueueID != "" {
- queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID})
- if err != nil {
- return nil, err
- }
- if len(queueItems) > 0 {
- return &types.ScheduleCopyDraftsData{
- Scheduled: 1,
- List: []types.PublishQueueItemData{publishQueueData(&queueItems[0])},
- Message: fmt.Sprintf("草稿 %s 已排程", draft.ID),
- }, nil
- }
- }
- scheduledAt := req.ScheduledAt
- if scheduledAt <= 0 {
- scheduledAt = clock.NowUnixNano()
- }
- item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
- TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
- PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID,
- Text: draft.Text, TopicTag: firstNonEmpty(req.TopicTag, draft.TopicTag), ScheduledAt: scheduledAt,
- })
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
- TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID,
- }); err != nil {
- _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID)
- return nil, err
- }
- return &types.ScheduleCopyDraftsData{
- Scheduled: 1,
- List: []types.PublishQueueItemData{publishQueueData(item)},
- Message: fmt.Sprintf("已排程草稿 %s", draft.ID),
- }, nil
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if value != "" {
- return value
- }
- }
- return ""
-}
diff --git a/old/backend/internal/logic/persona/schedule_persona_drafts_logic.go b/old/backend/internal/logic/persona/schedule_persona_drafts_logic.go
deleted file mode 100644
index fe22150..0000000
--- a/old/backend/internal/logic/persona/schedule_persona_drafts_logic.go
+++ /dev/null
@@ -1,107 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
- "fmt"
-
- "haixun-backend/internal/library/publishschedule"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type SchedulePersonaDraftsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewSchedulePersonaDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SchedulePersonaDraftsLogic {
- return &SchedulePersonaDraftsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *SchedulePersonaDraftsLogic) SchedulePersonaDrafts(req *types.SchedulePersonaDraftsHandlerReq) (resp *types.ScheduleCopyDraftsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.AccountID); err != nil {
- return nil, err
- }
- times := scheduleTimes(req.StartAt, req.Timezone, req.Slots, len(req.DraftIDs))
- list := make([]types.PublishQueueItemData, 0, len(req.DraftIDs))
- for idx, draftID := range req.DraftIDs {
- draft, err := l.svcCtx.CopyDraft.Get(l.ctx, tenantID, uid, req.ID, draftID)
- if err != nil {
- return nil, err
- }
- if draft.PublishQueueID != "" {
- queueItems, err := l.svcCtx.PublishQueue.ListByIDs(l.ctx, tenantID, uid, []string{draft.PublishQueueID})
- if err != nil {
- return nil, err
- }
- if len(queueItems) > 0 {
- list = append(list, publishQueueData(&queueItems[0]))
- continue
- }
- }
- scheduledAt := int64(0)
- if idx < len(times) {
- scheduledAt = times[idx]
- }
- item, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
- TenantID: tenantID, OwnerUID: uid, AccountID: req.AccountID,
- PersonaID: req.ID, CopyMissionID: draft.CopyMissionID, CopyDraftID: draft.ID,
- Text: draft.Text, TopicTag: draft.TopicTag, ScheduledAt: scheduledAt,
- })
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.CopyDraft.MarkScheduled(l.ctx, copydraftdomain.MarkScheduledRequest{
- TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, DraftID: draft.ID, QueueID: item.ID,
- }); err != nil {
- _ = l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.AccountID, item.ID)
- return nil, err
- }
- list = append(list, publishQueueData(item))
- }
- return &types.ScheduleCopyDraftsData{
- Scheduled: len(list), List: list, Message: fmt.Sprintf("已排程 %d 篇草稿", len(list)),
- }, nil
-}
-
-func scheduleTimes(startAt int64, timezone string, slots []types.PublishSlotData, count int) []int64 {
- converted := make([]publishschedule.Slot, 0, len(slots))
- for _, slot := range slots {
- converted = append(converted, publishschedule.Slot{Weekday: slot.Weekday, Time: slot.Time})
- }
- return publishschedule.BuildSchedule(startAt, timezone, converted, count)
-}
-
-func publishQueueData(item *pqdomain.QueueItemSummary) types.PublishQueueItemData {
- if item == nil {
- return types.PublishQueueItemData{}
- }
- return types.PublishQueueItemData{
- ID: item.ID, AccountID: item.AccountID, PersonaID: item.PersonaID,
- CopyMissionID: item.CopyMissionID, CopyDraftID: item.CopyDraftID,
- Text: item.Text, TopicTag: item.TopicTag, ScheduledAt: item.ScheduledAt, Status: item.Status,
- MediaID: item.MediaID, Permalink: item.Permalink, PublishedAt: item.PublishedAt,
- ErrorMessage: item.ErrorMessage, RetryCount: item.RetryCount, LastAttemptAt: item.LastAttemptAt,
- NextAttemptAt: item.NextAttemptAt, MissedAt: item.MissedAt, PausedReason: item.PausedReason,
- CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
- }
-}
diff --git a/old/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go b/old/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go
deleted file mode 100644
index 7faa3af..0000000
--- a/old/backend/internal/logic/persona/start_persona_formula_draft_job_logic.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type StartPersonaFormulaDraftJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPersonaFormulaDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaFormulaDraftJobLogic {
- return &StartPersonaFormulaDraftJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartPersonaFormulaDraftJobLogic) StartPersonaFormulaDraftJob(req *types.StartPersonaFormulaDraftJobHandlerReq) (*types.StartPersonaFormulaDraftJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- accountID := strings.TrimSpace(req.AccountID)
- formulaID := strings.TrimSpace(req.FormulaID)
- topic := strings.TrimSpace(req.Topic)
- if accountID == "" || formulaID == "" || topic == "" {
- return nil, app.For(code.Persona).InputMissingRequired("account_id, formula_id, and topic are required")
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
-
- count := req.DraftCount
- if count <= 0 {
- count = 1
- }
- if count > 5 {
- count = 5
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "generate-formula-draft",
- Scope: "persona",
- ScopeID: personaID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: map[string]any{
- "tenant_id": tenantID,
- "owner_uid": uid,
- "persona_id": personaID,
- "account_id": accountID,
- "formula_id": formulaID,
- "topic": topic,
- "brief": strings.TrimSpace(req.Brief),
- "use_web_search": req.UseWebSearch,
- "draft_count": count,
- "ai_provider": credential.Provider,
- "ai_model": credential.Model,
- },
- })
- if err != nil {
- return nil, err
- }
-
- return &types.StartPersonaFormulaDraftJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- AiProvider: credential.Provider,
- AiModel: credential.Model,
- Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model),
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go b/old/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go
deleted file mode 100644
index 76dee67..0000000
--- a/old/backend/internal/logic/persona/start_persona_rewrite_draft_job_logic.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type StartPersonaRewriteDraftJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPersonaRewriteDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaRewriteDraftJobLogic {
- return &StartPersonaRewriteDraftJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartPersonaRewriteDraftJobLogic) StartPersonaRewriteDraftJob(req *types.StartPersonaRewriteDraftJobHandlerReq) (*types.StartPersonaRewriteDraftJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- accountID := strings.TrimSpace(req.AccountID)
- referenceText := strings.TrimSpace(req.ReferenceText)
- topic := strings.TrimSpace(req.Topic)
- if accountID == "" || referenceText == "" || topic == "" {
- return nil, app.For(code.Persona).InputMissingRequired("account_id, reference_text, and topic are required")
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, accountID); err != nil {
- return nil, err
- }
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
-
- count := req.DraftCount
- if count <= 0 {
- count = 1
- }
- if count > 5 {
- count = 5
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "generate-rewrite-draft",
- Scope: "persona",
- ScopeID: personaID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: map[string]any{
- "tenant_id": tenantID,
- "owner_uid": uid,
- "persona_id": personaID,
- "account_id": accountID,
- "reference_text": referenceText,
- "topic": topic,
- "brief": strings.TrimSpace(req.Brief),
- "save_label": strings.TrimSpace(req.SaveLabel),
- "use_web_search": req.UseWebSearch,
- "draft_count": count,
- "ai_provider": credential.Provider,
- "ai_model": credential.Model,
- },
- })
- if err != nil {
- return nil, err
- }
-
- return &types.StartPersonaRewriteDraftJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- AiProvider: credential.Provider,
- AiModel: credential.Model,
- Message: fmt.Sprintf("貼文改寫已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model),
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go b/old/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go
deleted file mode 100644
index e806988..0000000
--- a/old/backend/internal/logic/persona/start_persona_style_analysis_from_text_logic.go
+++ /dev/null
@@ -1,154 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/style8d"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- personausecase "haixun-backend/internal/model/persona/domain/usecase"
- tausecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type StartPersonaStyleAnalysisFromTextLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPersonaStyleAnalysisFromTextLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaStyleAnalysisFromTextLogic {
- return &StartPersonaStyleAnalysisFromTextLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *StartPersonaStyleAnalysisFromTextLogic) StartPersonaStyleAnalysisFromText(
- req *types.StartPersonaStyleAnalysisFromTextHandlerReq,
-) (resp *types.StartPersonaStyleAnalysisFromTextData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if req == nil {
- return nil, app.For(code.Persona).InputMissingRequired("request is required")
- }
-
- texts, err := style8d.NormalizeReferenceTexts(req.ReferenceTexts, req.RawText)
- if err != nil {
- return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
- }
-
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
-
- activeAccountID := ""
- if member, memberErr := l.svcCtx.Member.GetByUID(l.ctx, tenantID, uid); memberErr == nil {
- activeAccountID = member.ActiveThreadsAccountID
- }
-
- credential, err := l.resolveResearchCredential(tenantID, uid, activeAccountID)
- if err != nil {
- return nil, err
- }
-
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- posts := style8d.TextsToPosts(texts)
- input := style8d.AnalyzeInput{
- Mode: style8d.AnalyzeModeManual,
- SourceLabel: strings.TrimSpace(req.SourceLabel),
- Brief: strings.TrimSpace(persona.Brief),
- Posts: posts,
- }
-
- systemPrompt, err := libprompt.Style8DSystem()
- if err != nil {
- return nil, app.For(code.AI).SysInternal("prompt config load failed")
- }
- maxTokens := 8192
- result, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: input.BuildUserPrompt()},
- },
- MaxTokens: &maxTokens,
- })
- if err != nil {
- if strings.Contains(err.Error(), "HTTP 401") {
- err = app.For(code.AI).SvcThirdParty(
- "8D AI 分析授權失敗:請到「設定 > 帳號 AI 設定」確認研究用 provider=" +
- credential.Provider + "、model=" + credential.Model + ",並重新貼上對應 provider 的 API key",
- )
- }
- return nil, err
- }
-
- parsed, err := style8d.ParseLLMOutput(result.Text)
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("8D LLM 回傳無法解析:" + err.Error())
- }
- profile := input.BuildStoredProfile(parsed)
- profileJSON, err := profile.JSON()
- if err != nil {
- return nil, err
- }
-
- benchmark := input.StyleBenchmark()
- personaDraft := strings.TrimSpace(profile.PersonaDraft)
- patch := personausecase.PersonaPatch{
- StyleProfile: &profileJSON,
- StyleBenchmark: &benchmark,
- }
- if personaDraft != "" {
- patch.Persona = &personaDraft
- }
- updated, err := l.svcCtx.Persona.Update(l.ctx, personausecase.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: req.ID,
- Patch: patch,
- })
- if err != nil {
- return nil, err
- }
-
- out := toPersonaData(*updated)
- return &types.StartPersonaStyleAnalysisFromTextData{
- Persona: out,
- PostCount: len(posts),
- Message: "8D 分析完成,風格檔案已寫入人設",
- }, nil
-}
-
-func (l *StartPersonaStyleAnalysisFromTextLogic) resolveResearchCredential(
- tenantID, uid, activeAccountID string,
-) (*tausecase.WorkerAiCredential, error) {
- accountID := strings.TrimSpace(activeAccountID)
- if accountID != "" {
- return l.svcCtx.ThreadsAccount.ResolveWorkerAiCredential(l.ctx, tenantID, uid, accountID)
- }
- return l.svcCtx.ThreadsAccount.ResolveMemberResearchAiCredential(l.ctx, tenantID, uid)
-}
diff --git a/old/backend/internal/logic/persona/start_persona_style_analysis_logic.go b/old/backend/internal/logic/persona/start_persona_style_analysis_logic.go
deleted file mode 100644
index 549923a..0000000
--- a/old/backend/internal/logic/persona/start_persona_style_analysis_logic.go
+++ /dev/null
@@ -1,75 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type StartPersonaStyleAnalysisLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPersonaStyleAnalysisLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaStyleAnalysisLogic {
- return &StartPersonaStyleAnalysisLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *StartPersonaStyleAnalysisLogic) StartPersonaStyleAnalysis(req *types.StartPersonaStyleAnalysisHandlerReq) (resp *types.StartPersonaStyleAnalysisData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- username := ""
- if req != nil {
- username = strings.TrimPrefix(strings.TrimSpace(req.BenchmarkUsername), "@")
- }
- if username == "" {
- return nil, app.For(code.Persona).InputMissingRequired("benchmark_username is required")
- }
-
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- activeAccountID := ""
- if member, err := l.svcCtx.Member.GetByUID(l.ctx, tenantID, uid); err == nil {
- activeAccountID = member.ActiveThreadsAccountID
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "style-8d",
- Scope: "persona",
- ScopeID: req.ID,
- Payload: map[string]any{
- "persona_id": req.ID,
- "benchmark_username": username,
- "tenant_id": tenantID,
- "owner_uid": uid,
- "threads_account_id": activeAccountID,
- },
- })
- if err != nil {
- return nil, err
- }
-
- return &types.StartPersonaStyleAnalysisData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "8D 分析已在背景執行,可自由切換頁面",
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go b/old/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go
deleted file mode 100644
index acf1f3b..0000000
--- a/old/backend/internal/logic/persona/start_persona_topic_matrix_job_logic.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package persona
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type StartPersonaTopicMatrixJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPersonaTopicMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaTopicMatrixJobLogic {
- return &StartPersonaTopicMatrixJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartPersonaTopicMatrixJobLogic) StartPersonaTopicMatrixJob(req *types.StartPersonaTopicMatrixJobHandlerReq) (*types.StartPersonaTopicMatrixJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- topic := strings.TrimSpace(req.Topic)
- if topic == "" {
- return nil, app.For(code.Persona).InputMissingRequired("topic is required")
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
-
- count := req.DraftCount
- if count <= 0 {
- count = 5
- }
- if count > 5 {
- count = 5
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "generate-topic-matrix",
- Scope: "persona",
- ScopeID: personaID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: map[string]any{
- "tenant_id": tenantID,
- "owner_uid": uid,
- "persona_id": personaID,
- "content_plan_id": strings.TrimSpace(req.ContentPlanID),
- "topic": topic,
- "brief": strings.TrimSpace(req.Brief),
- "use_web_search": req.UseWebSearch,
- "draft_count": count,
- "ai_provider": credential.Provider,
- "ai_model": credential.Model,
- },
- })
- if err != nil {
- return nil, err
- }
-
- return &types.StartPersonaTopicMatrixJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- AiProvider: credential.Provider,
- AiModel: credential.Model,
- Message: fmt.Sprintf("話題矩陣已在背景執行(%s / %s),可自由切換頁面", credential.Provider, credential.Model),
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/start_persona_viral_scan_job_logic.go b/old/backend/internal/logic/persona/start_persona_viral_scan_job_logic.go
deleted file mode 100644
index 9e285c0..0000000
--- a/old/backend/internal/logic/persona/start_persona_viral_scan_job_logic.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type StartPersonaViralScanJobLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPersonaViralScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPersonaViralScanJobLogic {
- return &StartPersonaViralScanJobLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartPersonaViralScanJobLogic) StartPersonaViralScanJob(
- req *types.StartPersonaViralScanJobHandlerReq,
-) (*types.StartPersonaViralScanJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- if personaID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("persona id is required")
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
-
- keywords := []string{}
- if req.StartPersonaViralScanJobReq.Keywords != nil {
- for _, kw := range req.StartPersonaViralScanJobReq.Keywords {
- kw = strings.TrimSpace(kw)
- if kw != "" {
- keywords = append(keywords, kw)
- }
- }
- }
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
- if !memberCtx.HasDiscoverPath() {
- return nil, app.For(code.Setting).InputMissingRequired("爆款掃描需要 Threads API、Chrome Session 或 Web Search API(請檢查連線模式與搜尋來源)")
- }
-
- payload := map[string]any{
- "persona_id": personaID,
- "keywords": keywords,
- "bootstrap": true,
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "scan-viral",
- Scope: "persona",
- ScopeID: personaID,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
-
- return &types.StartPersonaViralScanJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "爆款掃描已在背景執行,完成後可產出仿寫草稿",
- }, nil
-}
diff --git a/old/backend/internal/logic/persona/update_content_plan_logic.go b/old/backend/internal/logic/persona/update_content_plan_logic.go
deleted file mode 100644
index 97d939a..0000000
--- a/old/backend/internal/logic/persona/update_content_plan_logic.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- contentops "haixun-backend/internal/model/content_ops/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateContentPlanLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateContentPlanLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateContentPlanLogic {
- return &UpdateContentPlanLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateContentPlanLogic) UpdateContentPlan(req *types.UpdateContentPlanHandlerReq) (resp *types.ContentPlanData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentOps.UpdateContentPlan(l.ctx, tenantID, uid, req.ID, req.ContentPlanID, contentops.ContentPlanPatch{Topic: req.Topic, Mission: req.Mission, TargetAudience: req.TargetAudience, Angle: req.Angle, OpeningType: req.OpeningType, BodyType: req.BodyType, Emotion: req.Emotion, EndingType: req.EndingType, CtaType: req.CtaType, RiskLevel: req.RiskLevel, RequiresHumanReview: req.RequiresHumanReview, Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: req.Status})
- if err != nil {
- return nil, err
- }
- data := toContentPlanData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/persona/update_persona_copy_draft_logic.go b/old/backend/internal/logic/persona/update_persona_copy_draft_logic.go
deleted file mode 100644
index 5ce3bbb..0000000
--- a/old/backend/internal/logic/persona/update_persona_copy_draft_logic.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package persona
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/threadspost"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type UpdatePersonaCopyDraftLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdatePersonaCopyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePersonaCopyDraftLogic {
- return &UpdatePersonaCopyDraftLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdatePersonaCopyDraftLogic) UpdatePersonaCopyDraft(
- req *types.UpdateCopyDraftHandlerReq,
-) (*types.CopyDraftData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- personaID := strings.TrimSpace(req.ID)
- draftID := strings.TrimSpace(req.DraftID)
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID); err != nil {
- return nil, err
- }
- if req.Text != nil {
- if err := threadspost.ValidatePublish(*req.Text); err != nil {
- return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
- }
- }
-
- updated, err := l.svcCtx.CopyDraft.Update(l.ctx, copydraftdomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: personaID,
- DraftID: draftID,
- Patch: copydraftdomain.CopyDraftPatch{
- Text: req.Text,
- TopicTag: req.TopicTag,
- Hook: req.Hook,
- Angle: req.Angle,
- Rationale: req.Rationale,
- Status: req.Status,
- },
- })
- if err != nil {
- return nil, err
- }
- data := toCopyDraftData(*updated)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/persona/update_persona_logic.go b/old/backend/internal/logic/persona/update_persona_logic.go
deleted file mode 100644
index b532782..0000000
--- a/old/backend/internal/logic/persona/update_persona_logic.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/persona/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdatePersonaLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdatePersonaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePersonaLogic {
- return &UpdatePersonaLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdatePersonaLogic) UpdatePersona(req *types.UpdatePersonaHandlerReq) (resp *types.PersonaData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Persona.Update(l.ctx, domusecase.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- PersonaID: req.ID,
- Patch: toPersonaPatch(&req.UpdatePersonaReq),
- })
- if err != nil {
- return nil, err
- }
- out := toPersonaData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/persona/upsert_style_preset_logic.go b/old/backend/internal/logic/persona/upsert_style_preset_logic.go
deleted file mode 100644
index 33ca67a..0000000
--- a/old/backend/internal/logic/persona/upsert_style_preset_logic.go
+++ /dev/null
@@ -1,44 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package persona
-
-import (
- "context"
-
- stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpsertStylePresetLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertStylePresetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertStylePresetLogic {
- return &UpsertStylePresetLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpsertStylePresetLogic) UpsertStylePreset(req *types.UpsertStylePresetHandlerReq) (resp *types.StylePresetData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.StylePreset.Upsert(l.ctx, stylepresetdomain.UpsertRequest{
- TenantID: tenantID, OwnerUID: uid, PersonaID: req.ID, PresetID: req.PresetID,
- Name: req.Name, Tone: req.Tone, CTA: req.CTA, BannedWords: req.BannedWords, Notes: req.Notes, Apply: req.Apply,
- })
- if err != nil {
- return nil, err
- }
- data := toStylePresetData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/actor.go b/old/backend/internal/logic/placement_topic/actor.go
deleted file mode 100644
index d8db619..0000000
--- a/old/backend/internal/logic/placement_topic/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go b/old/backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go
deleted file mode 100644
index be483de..0000000
--- a/old/backend/internal/logic/placement_topic/batch_delete_placement_topic_scan_posts_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type BatchDeletePlacementTopicScanPostsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewBatchDeletePlacementTopicScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BatchDeletePlacementTopicScanPostsLogic {
- return &BatchDeletePlacementTopicScanPostsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *BatchDeletePlacementTopicScanPostsLogic) BatchDeletePlacementTopicScanPosts(req *types.BatchDeletePlacementTopicScanPostsHandlerReq) (resp *types.BatchDeletePlacementTopicScanPostsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- count, err := l.svcCtx.ScanPost.DeleteMany(l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, req.PostIDs)
- if err != nil {
- return nil, err
- }
- return &types.BatchDeletePlacementTopicScanPostsData{DeletedCount: count}, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/create_placement_topic_logic.go b/old/backend/internal/logic/placement_topic/create_placement_topic_logic.go
deleted file mode 100644
index 2c0eed6..0000000
--- a/old/backend/internal/logic/placement_topic/create_placement_topic_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "strings"
-
- topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type CreatePlacementTopicLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreatePlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePlacementTopicLogic {
- return &CreatePlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CreatePlacementTopicLogic) CreatePlacementTopic(req *types.CreatePlacementTopicHandlerReq) (*types.PlacementTopicData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PlacementTopic.Create(l.ctx, topicdomain.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: strings.TrimSpace(req.BrandID),
- TopicName: strings.TrimSpace(req.TopicName),
- SeedQuery: strings.TrimSpace(req.SeedQuery),
- Brief: strings.TrimSpace(req.Brief),
- ProductID: strings.TrimSpace(req.ProductID),
- })
- if err != nil {
- return nil, err
- }
- data := toPlacementTopicData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/delete_placement_topic_logic.go b/old/backend/internal/logic/placement_topic/delete_placement_topic_logic.go
deleted file mode 100644
index 77deed9..0000000
--- a/old/backend/internal/logic/placement_topic/delete_placement_topic_logic.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeletePlacementTopicLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeletePlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePlacementTopicLogic {
- return &DeletePlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeletePlacementTopicLogic) DeletePlacementTopic(req *types.PlacementTopicPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.PlacementTopic.Delete(l.ctx, tenantID, uid, req.ID)
-}
diff --git a/old/backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go b/old/backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go
deleted file mode 100644
index e86128e..0000000
--- a/old/backend/internal/logic/placement_topic/delete_placement_topic_scan_post_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type DeletePlacementTopicScanPostLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeletePlacementTopicScanPostLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePlacementTopicScanPostLogic {
- return &DeletePlacementTopicScanPostLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *DeletePlacementTopicScanPostLogic) DeletePlacementTopicScanPost(req *types.DeletePlacementTopicScanPostHandlerReq) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return err
- }
- return l.svcCtx.ScanPost.Delete(l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, req.PostID)
-}
diff --git a/old/backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go b/old/backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go
deleted file mode 100644
index dfff1bd..0000000
--- a/old/backend/internal/logic/placement_topic/expand_placement_topic_graph_logic.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ExpandPlacementTopicGraphLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewExpandPlacementTopicGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExpandPlacementTopicGraphLogic {
- return &ExpandPlacementTopicGraphLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ExpandPlacementTopicGraphLogic) ExpandPlacementTopicGraph(req *types.ExpandPlacementTopicGraphHandlerReq) (*types.ExpandKnowledgeGraphData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- seed := strings.TrimSpace(req.SeedQuery)
- if seed == "" {
- return nil, app.For(code.Brand).InputMissingRequired("seed_query is required")
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
-
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- expandStrategy := placement.EffectiveExpandStrategy(research)
- if req.Supplemental && placement.WebSearchAvailable(research) {
- expandStrategy = libkg.ExpandStrategyBrave
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
-
- payload := map[string]any{
- "topic_id": scope.TopicID,
- "brand_id": scope.BrandID,
- "seed_query": seed,
- "supplemental": req.Supplemental,
- "regenerate_map": req.RegenerateMap,
- "expand_strategy": expandStrategy.String(),
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
-
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "expand-graph",
- Scope: "placement_topic",
- ScopeID: scope.TopicID,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- return &types.ExpandKnowledgeGraphData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "研究地圖產生中,完成後可檢視延伸知識與參考連結",
- }, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go b/old/backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go
deleted file mode 100644
index ed1b4d3..0000000
--- a/old/backend/internal/logic/placement_topic/generate_placement_topic_content_matrix_logic.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GeneratePlacementTopicContentMatrixLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGeneratePlacementTopicContentMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePlacementTopicContentMatrixLogic {
- return &GeneratePlacementTopicContentMatrixLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GeneratePlacementTopicContentMatrixLogic) GeneratePlacementTopicContentMatrix(req *types.GeneratePlacementTopicContentMatrixHandlerReq) (*types.ContentMatrixData, error) {
- if _, _, err := actorFrom(l.ctx); err != nil {
- return nil, err
- }
- return brandlogic.NewGenerateBrandContentMatrixLogic(l.ctx, l.svcCtx).GenerateBrandContentMatrix(&types.GenerateContentMatrixHandlerReq{
- BrandPath: types.BrandPath{ID: req.ID},
- GenerateContentMatrixReq: types.GenerateContentMatrixReq{
- Count: req.Count,
- },
- })
-}
diff --git a/old/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go b/old/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go
deleted file mode 100644
index a00aac5..0000000
--- a/old/backend/internal/logic/placement_topic/generate_placement_topic_outreach_drafts_logic.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GeneratePlacementTopicOutreachDraftsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGeneratePlacementTopicOutreachDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GeneratePlacementTopicOutreachDraftsLogic {
- return &GeneratePlacementTopicOutreachDraftsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GeneratePlacementTopicOutreachDraftsLogic) GeneratePlacementTopicOutreachDrafts(req *types.GeneratePlacementTopicOutreachDraftsHandlerReq) (*types.GenerateOutreachDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- if strings.TrimSpace(req.VoicePersonaID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
- }
- productID := strings.TrimSpace(req.ProductID)
- if productID == "" {
- productID = strings.TrimSpace(scope.Topic.ProductID)
- }
- return brandlogic.NewGenerateOutreachDraftsLogic(l.ctx, l.svcCtx).GenerateOutreachDrafts(&types.GenerateOutreachDraftsHandlerReq{
- BrandPath: types.BrandPath{ID: scope.BrandID},
- GenerateOutreachDraftsReq: types.GenerateOutreachDraftsReq{
- ScanPostID: req.ScanPostID,
- TopicID: scope.TopicID,
- Count: req.Count,
- VoicePersonaID: req.VoicePersonaID,
- ProductID: productID,
- },
- })
-}
diff --git a/old/backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go b/old/backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go
deleted file mode 100644
index 564d9d9..0000000
--- a/old/backend/internal/logic/placement_topic/get_placement_topic_content_matrix_logic.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPlacementTopicContentMatrixLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPlacementTopicContentMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicContentMatrixLogic {
- return &GetPlacementTopicContentMatrixLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetPlacementTopicContentMatrixLogic) GetPlacementTopicContentMatrix(req *types.PlacementTopicPath) (*types.ContentMatrixData, error) {
- if _, _, err := actorFrom(l.ctx); err != nil {
- return nil, err
- }
- return brandlogic.NewGetBrandContentMatrixLogic(l.ctx, l.svcCtx).GetBrandContentMatrix(&types.BrandPath{ID: req.ID})
-}
diff --git a/old/backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go b/old/backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go
deleted file mode 100644
index 2ca43e1..0000000
--- a/old/backend/internal/logic/placement_topic/get_placement_topic_graph_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPlacementTopicGraphLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPlacementTopicGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicGraphLogic {
- return &GetPlacementTopicGraphLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetPlacementTopicGraphLogic) GetPlacementTopicGraph(req *types.PlacementTopicPath) (*types.KnowledgeGraphData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- graph, err := l.svcCtx.KnowledgeGraph.GetByTopic(l.ctx, tenantID, uid, req.ID, scope.BrandID)
- if err != nil {
- return nil, err
- }
- data := toKnowledgeGraphData(graph)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/get_placement_topic_logic.go b/old/backend/internal/logic/placement_topic/get_placement_topic_logic.go
deleted file mode 100644
index 8fc4f60..0000000
--- a/old/backend/internal/logic/placement_topic/get_placement_topic_logic.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPlacementTopicLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicLogic {
- return &GetPlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetPlacementTopicLogic) GetPlacementTopic(req *types.PlacementTopicPath) (*types.PlacementTopicData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PlacementTopic.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- data := toPlacementTopicData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go b/old/backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go
deleted file mode 100644
index c4f05a9..0000000
--- a/old/backend/internal/logic/placement_topic/get_placement_topic_scan_schedule_logic.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPlacementTopicScanScheduleLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPlacementTopicScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementTopicScanScheduleLogic {
- return &GetPlacementTopicScanScheduleLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetPlacementTopicScanScheduleLogic) GetPlacementTopicScanSchedule(req *types.PlacementTopicPath) (*types.BrandScanScheduleData, error) {
- if _, _, err := actorFrom(l.ctx); err != nil {
- return nil, err
- }
- return brandlogic.NewGetBrandScanScheduleLogic(l.ctx, l.svcCtx).GetBrandScanSchedule(&types.BrandPath{ID: req.ID})
-}
diff --git a/old/backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go b/old/backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go
deleted file mode 100644
index 3e1748d..0000000
--- a/old/backend/internal/logic/placement_topic/list_placement_topic_scan_posts_logic.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "strings"
-
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListPlacementTopicScanPostsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPlacementTopicScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlacementTopicScanPostsLogic {
- return &ListPlacementTopicScanPostsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListPlacementTopicScanPostsLogic) ListPlacementTopicScanPosts(req *types.ListPlacementTopicScanPostsHandlerReq) (*types.ListBrandScanPostsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- listReq := scanpostusecase.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: scope.BrandID,
- TopicID: scope.TopicID,
- Limit: 100,
- }
- listReq.Priority = strings.TrimSpace(req.Priority)
- listReq.Recent7dOnly = req.Recent7d
- listReq.ProductFitMin = req.ProductFitMin
- if req.Limit > 0 {
- listReq.Limit = req.Limit
- }
- posts, err := l.svcCtx.ScanPost.List(l.ctx, listReq)
- if err != nil {
- return nil, err
- }
- list := make([]types.ScanPostData, 0, len(posts))
- for _, post := range posts {
- if mapped := toScanPostData(&post); mapped != nil {
- if draft, draftErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, post.ID); draftErr != nil {
- return nil, draftErr
- } else if draft != nil {
- mapped.LatestDraft = toOutreachDraftData(draft)
- }
- list = append(list, *mapped)
- }
- }
- return &types.ListBrandScanPostsData{List: list, Total: len(list)}, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/list_placement_topics_logic.go b/old/backend/internal/logic/placement_topic/list_placement_topics_logic.go
deleted file mode 100644
index 40c0977..0000000
--- a/old/backend/internal/logic/placement_topic/list_placement_topics_logic.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListPlacementTopicsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPlacementTopicsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPlacementTopicsLogic {
- return &ListPlacementTopicsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListPlacementTopicsLogic) ListPlacementTopics() (*types.ListPlacementTopicsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PlacementTopic.List(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- return toListData(result), nil
-}
diff --git a/old/backend/internal/logic/placement_topic/mapper.go b/old/backend/internal/logic/placement_topic/mapper.go
deleted file mode 100644
index ab7f04b..0000000
--- a/old/backend/internal/logic/placement_topic/mapper.go
+++ /dev/null
@@ -1,225 +0,0 @@
-package placement_topic
-
-import (
- brandentity "haixun-backend/internal/model/brand/domain/entity"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- outreachusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
- domusecase "haixun-backend/internal/model/placement_topic/domain/usecase"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toPlacementTopicData(item domusecase.TopicSummary) types.PlacementTopicData {
- return types.PlacementTopicData{
- ID: item.ID,
- BrandID: item.BrandID,
- BrandDisplayName: item.BrandDisplayName,
- TopicName: item.TopicName,
- SeedQuery: item.SeedQuery,
- Brief: item.Brief,
- ProductID: item.ProductID,
- ResearchMap: toResearchMapData(item.ResearchMap),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toListData(result *domusecase.ListResult) *types.ListPlacementTopicsData {
- if result == nil {
- return &types.ListPlacementTopicsData{List: []types.PlacementTopicData{}}
- }
- list := make([]types.PlacementTopicData, 0, len(result.List))
- for _, item := range result.List {
- list = append(list, toPlacementTopicData(item))
- }
- return &types.ListPlacementTopicsData{List: list}
-}
-
-func toResearchMapData(item brandentity.ResearchMap) types.ResearchMapData {
- items := make([]types.ResearchItemData, 0, len(item.ResearchItems))
- for _, researchItem := range item.ResearchItems {
- items = append(items, types.ResearchItemData{
- Title: researchItem.Title,
- URL: researchItem.URL,
- Snippet: researchItem.Snippet,
- Query: researchItem.Query,
- })
- }
- return types.ResearchMapData{
- AudienceSummary: item.AudienceSummary,
- ContentGoal: item.ContentGoal,
- Questions: item.Questions,
- Pillars: item.Pillars,
- Exclusions: item.Exclusions,
- ResearchItems: items,
- ExpandStrategy: item.ExpandStrategy,
- PatrolKeywords: append([]string(nil), item.PatrolKeywords...),
- }
-}
-
-func toTopicPatch(req *types.UpdatePlacementTopicReq) domusecase.TopicPatch {
- if req == nil {
- return domusecase.TopicPatch{}
- }
- patch := domusecase.TopicPatch{
- BrandID: req.BrandID,
- TopicName: req.TopicName,
- SeedQuery: req.SeedQuery,
- Brief: req.Brief,
- ProductID: req.ProductID,
- }
- if req.AudienceSummary != nil {
- patch.AudienceSummary = req.AudienceSummary
- }
- if req.ContentGoal != nil {
- patch.ContentGoal = req.ContentGoal
- }
- if req.Questions != nil {
- patch.QuestionsSet = true
- patch.Questions = append([]string(nil), req.Questions...)
- }
- if req.Pillars != nil {
- patch.PillarsSet = true
- patch.Pillars = append([]string(nil), req.Pillars...)
- }
- if req.Exclusions != nil {
- patch.ExclusionsSet = true
- patch.Exclusions = append([]string(nil), req.Exclusions...)
- }
- if req.PatrolKeywords != nil {
- patch.PatrolKeywordsSet = true
- patch.PatrolKeywords = append([]string(nil), req.PatrolKeywords...)
- }
- return patch
-}
-
-func toKnowledgeGraphData(graph *kgusecase.GraphSummary) types.KnowledgeGraphData {
- if graph == nil {
- return types.KnowledgeGraphData{}
- }
- nodes := make([]types.KnowledgeGraphNodeData, 0, len(graph.Nodes))
- for _, node := range graph.Nodes {
- evidence := make([]types.KnowledgeGraphEvidenceData, 0, len(node.Evidence))
- for _, item := range node.Evidence {
- evidence = append(evidence, types.KnowledgeGraphEvidenceData{
- URL: item.URL,
- Snippet: item.Snippet,
- Query: item.Query,
- })
- }
- nodes = append(nodes, types.KnowledgeGraphNodeData{
- ID: node.ID,
- Label: node.Label,
- NodeKind: node.NodeKind,
- Type: node.Type,
- Layer: node.Layer,
- Relation: node.Relation,
- PlacementValue: node.PlacementValue,
- ProductFitScore: node.ProductFitScore,
- SelectedForScan: node.SelectedForScan,
- RelevanceTags: append([]string{}, node.DerivedTags.Relevance...),
- RecencyTags: append([]string{}, node.DerivedTags.Recency...),
- Evidence: evidence,
- })
- }
- edges := make([]types.KnowledgeGraphEdgeData, 0, len(graph.Edges))
- for _, edge := range graph.Edges {
- edges = append(edges, types.KnowledgeGraphEdgeData{
- From: edge.From,
- To: edge.To,
- Relation: edge.Relation,
- })
- }
- sources := make([]types.BraveSourceData, 0, len(graph.BraveSources))
- for _, src := range graph.BraveSources {
- sources = append(sources, types.BraveSourceData{
- Query: src.Query,
- Title: src.Title,
- URL: src.URL,
- Snippet: src.Snippet,
- })
- }
- return types.KnowledgeGraphData{
- ID: graph.ID,
- BrandID: graph.BrandID,
- Seed: graph.Seed,
- Nodes: nodes,
- Edges: edges,
- BraveSources: sources,
- ExpandStrategy: graph.ExpandStrategy,
- PainTagCount: graph.PainTagCount,
- GeneratedAt: graph.GeneratedAt,
- CreateAt: graph.CreateAt,
- UpdateAt: graph.UpdateAt,
- }
-}
-
-func toScanPostData(post *scanpostusecase.ScanPostSummary) *types.ScanPostData {
- if post == nil {
- return nil
- }
- return &types.ScanPostData{
- ID: post.ID,
- GraphNodeID: post.GraphNodeID,
- SearchTag: post.SearchTag,
- QueryDimension: post.QueryDimension,
- RecencyDays: post.RecencyDays,
- ExternalID: post.ExternalID,
- Permalink: post.Permalink,
- Author: post.Author,
- Text: post.Text,
- Priority: post.Priority,
- PlacementScore: post.PlacementScore,
- ProductFitScore: post.ProductFitScore,
- SolvedByProduct: post.SolvedByProduct,
- Source: post.Source,
- ScanJobID: post.ScanJobID,
- OutreachStatus: post.OutreachStatus,
- PublishedReplyID: post.PublishedReplyID,
- PublishedPermalink: post.PublishedPermalink,
- OutreachUpdateAt: post.OutreachUpdateAt,
- PostedAt: post.PostedAt,
- Replies: toScanReplyData(post.Replies),
- CreateAt: post.CreateAt,
- }
-}
-
-func toScanReplyData(replies []scanpostusecase.ScanReplySummary) []types.ScanReplyData {
- if len(replies) == 0 {
- return nil
- }
- out := make([]types.ScanReplyData, 0, len(replies))
- for _, reply := range replies {
- out = append(out, types.ScanReplyData{
- ExternalID: reply.ExternalID,
- Author: reply.Author,
- Text: reply.Text,
- Permalink: reply.Permalink,
- LikeCount: reply.LikeCount,
- PostedAt: reply.PostedAt,
- })
- }
- return out
-}
-
-func toOutreachDraftData(saved *outreachusecase.DraftSummary) *types.GenerateOutreachDraftsData {
- if saved == nil {
- return nil
- }
- drafts := make([]types.OutreachDraftItemData, 0, len(saved.Drafts))
- for _, item := range saved.Drafts {
- drafts = append(drafts, types.OutreachDraftItemData{
- Text: item.Text,
- Angle: item.Angle,
- Rationale: item.Rationale,
- })
- }
- return &types.GenerateOutreachDraftsData{
- ID: saved.ID,
- ScanPostID: saved.ScanPostID,
- Relevance: saved.Relevance,
- Reason: saved.Reason,
- Drafts: drafts,
- CreateAt: saved.CreateAt,
- }
-}
diff --git a/old/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go b/old/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go
deleted file mode 100644
index 143f18e..0000000
--- a/old/backend/internal/logic/placement_topic/patch_placement_topic_graph_nodes_logic.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PatchPlacementTopicGraphNodesLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPatchPlacementTopicGraphNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPlacementTopicGraphNodesLogic {
- return &PatchPlacementTopicGraphNodesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PatchPlacementTopicGraphNodesLogic) PatchPlacementTopicGraphNodes(req *types.PatchPlacementTopicGraphNodesHandlerReq) (*types.KnowledgeGraphData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if len(req.Updates) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("updates is required")
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- updates := make([]kgusecase.NodeUpdate, 0, len(req.Updates))
- for _, item := range req.Updates {
- nodeID := strings.TrimSpace(item.NodeID)
- if nodeID == "" {
- continue
- }
- update := kgusecase.NodeUpdate{NodeID: nodeID}
- if item.SelectedForScan != nil {
- update.SelectedForScan = item.SelectedForScan
- }
- if item.RelevanceTags != nil {
- update.RelevanceTagsSet = true
- update.RelevanceTags = append([]string(nil), item.RelevanceTags...)
- }
- if item.RecencyTags != nil {
- update.RecencyTagsSet = true
- update.RecencyTags = append([]string(nil), item.RecencyTags...)
- }
- if update.SelectedForScan == nil && !update.RelevanceTagsSet && !update.RecencyTagsSet {
- return nil, app.For(code.Brand).InputMissingRequired("each update needs selected_for_scan or patrol tags")
- }
- updates = append(updates, update)
- }
- if len(updates) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("updates is required")
- }
- graph, err := l.svcCtx.KnowledgeGraph.UpdateNodes(l.ctx, kgusecase.UpdateNodesRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- BrandID: scope.BrandID,
- TopicID: req.ID,
- Updates: updates,
- })
- if err != nil {
- return nil, err
- }
- data := toKnowledgeGraphData(graph)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go b/old/backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go
deleted file mode 100644
index fe67aa6..0000000
--- a/old/backend/internal/logic/placement_topic/patch_placement_topic_scan_post_outreach_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PatchPlacementTopicScanPostOutreachLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPatchPlacementTopicScanPostOutreachLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPlacementTopicScanPostOutreachLogic {
- return &PatchPlacementTopicScanPostOutreachLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PatchPlacementTopicScanPostOutreachLogic) PatchPlacementTopicScanPostOutreach(req *types.PatchPlacementTopicScanPostOutreachHandlerReq) (*types.ScanPostData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return brandlogic.NewPatchScanPostOutreachLogic(l.ctx, l.svcCtx).PatchScanPostOutreach(&types.PatchScanPostOutreachHandlerReq{
- BrandPath: types.BrandPath{ID: scope.BrandID},
- PostID: req.PostID,
- PatchScanPostOutreachReq: types.PatchScanPostOutreachReq{
- OutreachStatus: req.OutreachStatus,
- },
- })
-}
diff --git a/old/backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go b/old/backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go
deleted file mode 100644
index bbf45e9..0000000
--- a/old/backend/internal/logic/placement_topic/publish_placement_topic_outreach_draft_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PublishPlacementTopicOutreachDraftLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPublishPlacementTopicOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishPlacementTopicOutreachDraftLogic {
- return &PublishPlacementTopicOutreachDraftLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PublishPlacementTopicOutreachDraftLogic) PublishPlacementTopicOutreachDraft(req *types.PublishPlacementTopicOutreachDraftHandlerReq) (*types.PublishOutreachDraftData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return brandlogic.NewPublishOutreachDraftLogic(l.ctx, l.svcCtx).PublishOutreachDraft(&types.PublishOutreachDraftHandlerReq{
- BrandPath: types.BrandPath{ID: scope.BrandID},
- PublishOutreachDraftReq: types.PublishOutreachDraftReq{
- ScanPostID: req.ScanPostID,
- Text: req.Text,
- Confirm: req.Confirm,
- },
- })
-}
diff --git a/old/backend/internal/logic/placement_topic/scope.go b/old/backend/internal/logic/placement_topic/scope.go
deleted file mode 100644
index d5e82de..0000000
--- a/old/backend/internal/logic/placement_topic/scope.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
- "haixun-backend/internal/svc"
-)
-
-type WorkflowScope struct {
- TopicID string
- BrandID string
- Topic topicdomain.TopicSummary
- Brand branddomain.BrandSummary
-}
-
-func resolveScope(ctx context.Context, svcCtx *svc.ServiceContext, tenantID, uid, topicID string) (*WorkflowScope, error) {
- topic, err := svcCtx.PlacementTopic.Get(ctx, tenantID, uid, topicID)
- if err != nil {
- return nil, err
- }
- brand, err := svcCtx.Brand.Get(ctx, tenantID, uid, topic.BrandID)
- if err != nil {
- return nil, err
- }
- return &WorkflowScope{
- TopicID: topic.ID,
- BrandID: topic.BrandID,
- Topic: *topic,
- Brand: *brand,
- }, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go b/old/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go
deleted file mode 100644
index f190908..0000000
--- a/old/backend/internal/logic/placement_topic/start_placement_topic_outreach_draft_job_logic.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type StartPlacementTopicOutreachDraftJobLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPlacementTopicOutreachDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPlacementTopicOutreachDraftJobLogic {
- return &StartPlacementTopicOutreachDraftJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartPlacementTopicOutreachDraftJobLogic) StartPlacementTopicOutreachDraftJob(
- req *types.StartPlacementTopicOutreachDraftJobHandlerReq,
-) (*types.StartPlacementTopicOutreachDraftJobsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
-
- scanPostIDs := append([]string{}, req.ScanPostIDs...)
- if id := strings.TrimSpace(req.ScanPostID); id != "" {
- scanPostIDs = append(scanPostIDs, id)
- }
- seen := map[string]struct{}{}
- unique := make([]string, 0, len(scanPostIDs))
- for _, id := range scanPostIDs {
- id = strings.TrimSpace(id)
- if id == "" {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- unique = append(unique, id)
- }
- if len(unique) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("scan_post_id or scan_post_ids is required")
- }
- if strings.TrimSpace(req.VoicePersonaID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("voice_persona_id is required")
- }
- if _, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, req.VoicePersonaID); err != nil {
- return nil, err
- }
-
- count := req.Count
- if count <= 0 {
- count = 3
- }
- if count > 4 {
- count = 4
- }
-
- productID := strings.TrimSpace(req.ProductID)
- if productID == "" {
- productID = strings.TrimSpace(scope.Topic.ProductID)
- }
-
- jobs := make([]types.StartBrandScanJobData, 0, len(unique))
- for _, scanPostID := range unique {
- if _, err := l.svcCtx.ScanPost.Get(l.ctx, tenantID, uid, scope.BrandID, scanPostID); err != nil {
- return nil, err
- }
- regenerate := false
- if existing, existingErr := l.svcCtx.OutreachDraft.GetLatestByScanPost(
- l.ctx, tenantID, uid, scope.BrandID, scope.TopicID, scanPostID,
- ); existingErr == nil && existing != nil && len(existing.Drafts) > 0 {
- regenerate = true
- }
- payload := map[string]any{
- "brand_id": scope.BrandID,
- "topic_id": scope.TopicID,
- "scan_post_id": scanPostID,
- "count": count,
- "voice_persona_id": strings.TrimSpace(req.VoicePersonaID),
- "regenerate": regenerate,
- }
- if productID != "" {
- payload["product_id"] = productID
- }
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "generate-outreach-draft",
- Scope: "placement_topic",
- ScopeID: scope.TopicID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- jobs = append(jobs, types.StartBrandScanJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "獲客草稿背景任務已建立",
- })
- }
-
- message := "獲客草稿已在背景執行,可繼續瀏覽其他頁面"
- if len(jobs) > 1 {
- message = fmt.Sprintf("已送出 %d 個獲客草稿背景任務,可繼續瀏覽其他頁面", len(jobs))
- }
- return &types.StartPlacementTopicOutreachDraftJobsData{
- Jobs: jobs,
- Message: message,
- }, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go b/old/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go
deleted file mode 100644
index 7a27e83..0000000
--- a/old/backend/internal/logic/placement_topic/start_placement_topic_scan_job_logic.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package placement_topic
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- brandlogic "haixun-backend/internal/logic/brand"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- kgdom "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type StartPlacementTopicScanJobLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartPlacementTopicScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPlacementTopicScanJobLogic {
- return &StartPlacementTopicScanJobLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartPlacementTopicScanJobLogic) StartPlacementTopicScanJob(req *types.StartPlacementTopicScanJobHandlerReq) (*types.StartBrandScanJobData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- var graph *kgdom.GraphSummary
- if loaded, err := l.svcCtx.KnowledgeGraph.GetByTopic(l.ctx, tenantID, uid, scope.TopicID, scope.BrandID); err != nil {
- if !brandlogic.IsKnowledgeGraphNotFound(err) {
- return nil, err
- }
- } else {
- graph = loaded
- }
- selected := 0
- if graph != nil {
- for _, node := range graph.Nodes {
- if node.SelectedForScan {
- selected++
- }
- }
- }
- nodeIDs := []string{}
- for _, id := range req.NodeIDs {
- id = strings.TrimSpace(id)
- if id != "" {
- nodeIDs = append(nodeIDs, id)
- }
- }
- graphID := scope.TopicID
- if graph != nil && strings.TrimSpace(graph.ID) != "" {
- graphID = graph.ID
- }
- if strings.TrimSpace(req.GraphID) != "" {
- graphID = strings.TrimSpace(req.GraphID)
- }
- dualTrack := true
- patrolMode := req.PatrolMode
- testPatrol := req.TestPatrol
- brandForPatrol := scope.Brand
- brandForPatrol.ProductID = scope.Topic.ProductID
- brandForPatrol.ResearchMap = scope.Topic.ResearchMap
- productBrief := strings.TrimSpace(brandForPatrol.ProductBrief)
- if formatted := placement.ProductBriefFromContext(brandForPatrol.ProductContext); formatted != "" {
- productBrief = formatted
- }
- patrolInput := libkg.PatrolTagInputFromBrand(&brandForPatrol, productBrief)
- patrolNodes := []libkg.Node{}
- if graph != nil {
- patrolNodes = graph.Nodes
- }
- if len(nodeIDs) == 0 && selected > 0 && graph != nil {
- for _, node := range graph.Nodes {
- if node.SelectedForScan {
- nodeIDs = append(nodeIDs, node.ID)
- }
- }
- }
- patrolKeywords := libkg.ResolveScanPatrolKeywords(
- req.PatrolKeywords,
- scope.Topic.ResearchMap.PatrolKeywords,
- patrolInput,
- patrolNodes,
- )
- if patrolMode || selected > 0 || len(nodeIDs) > 0 {
- if len(patrolKeywords) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("請先勾選痛點節點或產生研究地圖,系統會綜合知識圖譜與產品自動整理海巡關鍵字")
- }
- patrolMode = true
- } else if graph == nil {
- return nil, app.For(code.Brand).ResNotFound("請先產生延伸知識圖譜,或改用手動海巡關鍵字")
- }
- research, err := l.svcCtx.Placement.ResearchSettings(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- placementSettings, err := l.svcCtx.Placement.Get(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- memberCtx, err := l.svcCtx.ThreadsAccount.ResolveMemberPlacementContext(l.ctx, tenantID, uid, research)
- if err != nil {
- return nil, err
- }
- if testPatrol {
- if placementSettings == nil || !placementSettings.DevModeEnabled {
- return nil, app.For(code.Setting).InputMissingRequired("請先在設定頁開啟「開發模式(測試海巡)」")
- }
- if !memberCtx.BrowserConnected {
- return nil, app.For(code.Setting).InputMissingRequired("測試海巡需先同步 Chrome Extension Session")
- }
- memberCtx = placement.MemberContextForCrawlerOnly(memberCtx)
- patrolMode = true
- } else {
- if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
- return nil, app.For(code.Setting).InputMissingRequired("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session")
- }
- if placement.MemberNeedsWebSearchKey(memberCtx) && placement.MissingWebSearchKey(research) {
- return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(research))
- }
- if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
- return nil, app.For(code.Setting).InputMissingRequired("請先完成 Threads API 連線後再開始海巡")
- }
- }
- payload := map[string]any{
- "topic_id": scope.TopicID,
- "brand_id": scope.BrandID,
- "graph_id": graphID,
- "dual_track": dualTrack,
- "node_ids": nodeIDs,
- }
- if patrolMode {
- payload["patrol_mode"] = true
- payload["patrol_keywords"] = patrolKeywords
- }
- if testPatrol {
- payload["test_patrol"] = true
- }
- for key, value := range memberCtx.PayloadFields() {
- payload[key] = value
- }
- run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
- TemplateType: "placement-scan",
- Scope: "placement_topic",
- ScopeID: scope.TopicID,
- TenantID: tenantID,
- OwnerUID: uid,
- Payload: payload,
- })
- if err != nil {
- return nil, err
- }
- return &types.StartBrandScanJobData{
- JobID: run.ID.Hex(),
- Status: string(run.Status),
- Message: "雙軌海巡已在背景執行,完成後可到獲客台查看貼文",
- }, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/update_placement_topic_logic.go b/old/backend/internal/logic/placement_topic/update_placement_topic_logic.go
deleted file mode 100644
index f2c8a18..0000000
--- a/old/backend/internal/logic/placement_topic/update_placement_topic_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdatePlacementTopicLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdatePlacementTopicLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePlacementTopicLogic {
- return &UpdatePlacementTopicLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdatePlacementTopicLogic) UpdatePlacementTopic(req *types.UpdatePlacementTopicHandlerReq) (*types.PlacementTopicData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PlacementTopic.Update(l.ctx, topicdomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- TopicID: req.ID,
- Patch: toTopicPatch(&req.UpdatePlacementTopicReq),
- })
- if err != nil {
- return nil, err
- }
- data := toPlacementTopicData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go b/old/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go
deleted file mode 100644
index 4a17552..0000000
--- a/old/backend/internal/logic/placement_topic/update_placement_topic_outreach_draft_logic.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdatePlacementTopicOutreachDraftLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdatePlacementTopicOutreachDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdatePlacementTopicOutreachDraftLogic {
- return &UpdatePlacementTopicOutreachDraftLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdatePlacementTopicOutreachDraftLogic) UpdatePlacementTopicOutreachDraft(
- req *types.UpdatePlacementTopicOutreachDraftHandlerReq,
-) (*types.GenerateOutreachDraftsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- scope, err := resolveScope(l.ctx, l.svcCtx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return brandlogic.NewUpdateOutreachDraftLogic(l.ctx, l.svcCtx).UpdateOutreachDraft(&types.UpdateOutreachDraftHandlerReq{
- BrandPath: types.BrandPath{ID: scope.BrandID},
- DraftID: req.DraftID,
- UpdateOutreachDraftReq: types.UpdateOutreachDraftReq{
- DraftIndex: req.DraftIndex,
- Text: req.Text,
- },
- })
-}
diff --git a/old/backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go b/old/backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go
deleted file mode 100644
index 56ef8c3..0000000
--- a/old/backend/internal/logic/placement_topic/upsert_placement_topic_scan_schedule_logic.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package placement_topic
-
-import (
- "context"
-
- brandlogic "haixun-backend/internal/logic/brand"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpsertPlacementTopicScanScheduleLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertPlacementTopicScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPlacementTopicScanScheduleLogic {
- return &UpsertPlacementTopicScanScheduleLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpsertPlacementTopicScanScheduleLogic) UpsertPlacementTopicScanSchedule(req *types.UpsertPlacementTopicScanScheduleHandlerReq) (*types.BrandScanScheduleData, error) {
- if _, _, err := actorFrom(l.ctx); err != nil {
- return nil, err
- }
- return brandlogic.NewUpsertBrandScanScheduleLogic(l.ctx, l.svcCtx).UpsertBrandScanSchedule(&types.UpsertBrandScanScheduleHandlerReq{
- BrandPath: types.BrandPath{ID: req.ID},
- UpsertBrandScanScheduleReq: types.UpsertBrandScanScheduleReq{
- Cron: req.Cron,
- Timezone: req.Timezone,
- Enabled: req.Enabled,
- },
- })
-}
diff --git a/old/backend/internal/logic/setting/authz.go b/old/backend/internal/logic/setting/authz.go
deleted file mode 100644
index 471e440..0000000
--- a/old/backend/internal/logic/setting/authz.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package setting
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/svc"
-)
-
-// authorizeSettingAccess guards the generic settings API against horizontal
-// privilege escalation (IDOR). The settings collection stores sensitive values
-// such as AI / Brave / Exa API keys under the "user" and "account" scopes, so
-// the caller must be the owner of the scope being accessed.
-//
-// - "user": scope_id must equal the caller uid (uid is enumerable).
-// - "account": the threads account must belong to the caller.
-// - other scopes (brand / persona / placement_topic / copy_mission ...):
-// keyed by unguessable ObjectIDs and hold non-secret research config;
-// ownership is enforced by their own feature endpoints. We still require an
-// authenticated actor here.
-func authorizeSettingAccess(ctx context.Context, svcCtx *svc.ServiceContext, scope, scopeID string) error {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok || actor.UID == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
-
- switch scope {
- case "user":
- if scopeID != actor.UID {
- return app.For(code.Setting).AuthForbidden("cannot access another user's settings")
- }
- case "account":
- if _, err := svcCtx.ThreadsAccount.Get(ctx, actor.TenantID, actor.UID, scopeID); err != nil {
- return app.For(code.Setting).AuthForbidden("cannot access settings of an account you do not own")
- }
- }
-
- return nil
-}
diff --git a/old/backend/internal/logic/setting/delete_setting_logic.go b/old/backend/internal/logic/setting/delete_setting_logic.go
deleted file mode 100644
index 3113a69..0000000
--- a/old/backend/internal/logic/setting/delete_setting_logic.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package setting
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DeleteSettingLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteSettingLogic {
- return &DeleteSettingLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeleteSettingLogic) DeleteSetting(req *types.SettingKeyPath) error {
- if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
- return err
- }
- return l.svcCtx.Setting.Delete(l.ctx, req.Scope, req.ScopeID, req.Key)
-}
diff --git a/old/backend/internal/logic/setting/get_setting_logic.go b/old/backend/internal/logic/setting/get_setting_logic.go
deleted file mode 100644
index 3ea83d5..0000000
--- a/old/backend/internal/logic/setting/get_setting_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package setting
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetSettingLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSettingLogic {
- return &GetSettingLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetSettingLogic) GetSetting(req *types.SettingKeyPath) (*types.SettingData, error) {
- if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Setting.Get(l.ctx, req.Scope, req.ScopeID, req.Key)
- if err != nil {
- return nil, err
- }
- data := toSettingData(item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/setting/list_settings_logic.go b/old/backend/internal/logic/setting/list_settings_logic.go
deleted file mode 100644
index f63fdb8..0000000
--- a/old/backend/internal/logic/setting/list_settings_logic.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package setting
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListSettingsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListSettingsLogic {
- return &ListSettingsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListSettingsLogic) ListSettings(req *types.SettingPath) (*types.SettingListData, error) {
- if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
- return nil, err
- }
- items, total, page, pageSize, err := l.svcCtx.Setting.List(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
- if err != nil {
- return nil, err
- }
- data := make([]types.SettingData, 0, len(items))
- for _, item := range items {
- data = append(data, toSettingData(item))
- }
- totalPages := int64(0)
- if pageSize > 0 {
- totalPages = (total + pageSize - 1) / pageSize
- }
- return &types.SettingListData{
- Pagination: types.PaginationData{
- Total: total,
- Page: page,
- PageSize: pageSize,
- TotalPages: totalPages,
- },
- List: data,
- }, nil
-}
diff --git a/old/backend/internal/logic/setting/mapper.go b/old/backend/internal/logic/setting/mapper.go
deleted file mode 100644
index 36825f0..0000000
--- a/old/backend/internal/logic/setting/mapper.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package setting
-
-import (
- "haixun-backend/internal/model/setting/domain/entity"
- "haixun-backend/internal/types"
-)
-
-func toSettingData(item *entity.Setting) types.SettingData {
- return types.SettingData{
- ID: item.ID.Hex(),
- Scope: item.Scope,
- ScopeID: item.ScopeID,
- Key: item.Key,
- Value: item.Value,
- Version: item.Version,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
diff --git a/old/backend/internal/logic/setting/upsert_setting_logic.go b/old/backend/internal/logic/setting/upsert_setting_logic.go
deleted file mode 100644
index c9a2cee..0000000
--- a/old/backend/internal/logic/setting/upsert_setting_logic.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package setting
-
-import (
- "context"
-
- settinguc "haixun-backend/internal/model/setting/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type UpsertSettingLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertSettingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertSettingLogic {
- return &UpsertSettingLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpsertSettingLogic) UpsertSetting(req *types.SettingUpsertReq) (*types.SettingData, error) {
- if err := authorizeSettingAccess(l.ctx, l.svcCtx, req.Scope, req.ScopeID); err != nil {
- return nil, err
- }
- item, err := l.svcCtx.Setting.Upsert(l.ctx, settinguc.UpsertRequest{
- Scope: req.Scope,
- ScopeID: req.ScopeID,
- Key: req.Key,
- Value: req.Value,
- Version: req.Version,
- })
- if err != nil {
- return nil, err
- }
- data := toSettingData(item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/threads_account/activate_threads_account_logic.go b/old/backend/internal/logic/threads_account/activate_threads_account_logic.go
deleted file mode 100644
index 323831e..0000000
--- a/old/backend/internal/logic/threads_account/activate_threads_account_logic.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ActivateThreadsAccountLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewActivateThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ActivateThreadsAccountLogic {
- return &ActivateThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ActivateThreadsAccountLogic) ActivateThreadsAccount(req *types.ThreadsAccountPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.ThreadsAccount.Activate(l.ctx, tenantID, uid, req.ID)
-}
diff --git a/old/backend/internal/logic/threads_account/actor.go b/old/backend/internal/logic/threads_account/actor.go
deleted file mode 100644
index 02883fd..0000000
--- a/old/backend/internal/logic/threads_account/actor.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func actorFrom(ctx context.Context) (tenantID, uid string, err error) {
- actor, ok := authctx.ActorFromContext(ctx)
- if !ok {
- return "", "", app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return actor.TenantID, actor.UID, nil
-}
diff --git a/old/backend/internal/logic/threads_account/analyze_content_formula_logic.go b/old/backend/internal/logic/threads_account/analyze_content_formula_logic.go
deleted file mode 100644
index 12075ec..0000000
--- a/old/backend/internal/logic/threads_account/analyze_content_formula_logic.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package threads_account
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libformula "haixun-backend/internal/library/formula"
- liboutreach "haixun-backend/internal/library/outreach"
- libownpost "haixun-backend/internal/library/ownpost"
- libviral "haixun-backend/internal/library/viral"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- cfentity "haixun-backend/internal/model/content_formula/domain/entity"
- cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type AnalyzeContentFormulaLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewAnalyzeContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AnalyzeContentFormulaLogic {
- return &AnalyzeContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *AnalyzeContentFormulaLogic) AnalyzeContentFormula(req *types.AnalyzeContentFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- sourceType := strings.TrimSpace(req.SourceType)
- if sourceType == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("source_type is required")
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
- aiReq := domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{APIKey: credential.APIKey},
- }
- personaBlock := l.resolvePersonaBlock(tenantID, uid, req.PersonaID)
-
- var analysis *libformula.PastedAnalysis
- var sourceRef, postText, author, permalink string
- var metrics *cfentity.SourceMetrics
-
- switch sourceType {
- case cfentity.SourcePaste:
- postText = strings.TrimSpace(req.PostText)
- if postText == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required for paste")
- }
- author = strings.TrimSpace(req.AuthorName)
- analysis, err = libformula.AnalyzePasted(l.ctx, l.svcCtx.AI, aiReq, libformula.AnalyzePastedInput{
- PostText: postText, AuthorName: author, PersonaBlock: personaBlock,
- LikeCount: req.LikeCount, ReplyCount: req.ReplyCount,
- })
- sourceRef = "paste"
- case cfentity.SourceOwnPost:
- mediaID := strings.TrimSpace(req.MediaID)
- if mediaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required for own_post")
- }
- review, rerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, mediaID)
- if rerr != nil || review == nil || req.ForceRefresh {
- gen := NewGenerateOwnPostFormulaLogic(l.ctx, l.svcCtx)
- force := req.ForceRefresh
- genData, genErr := gen.GenerateOwnPostFormula(&types.GenerateOwnPostFormulaHandlerReq{
- ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID},
- GenerateOwnPostFormulaReq: types.GenerateOwnPostFormulaReq{
- MediaID: mediaID, PersonaID: req.PersonaID, PostText: req.PostText,
- LikeCount: req.LikeCount, ReplyCount: req.ReplyCount, Views: req.Views,
- Force: force,
- },
- })
- if genErr != nil {
- return nil, genErr
- }
- analysis = &libformula.PastedAnalysis{
- Summary: genData.Summary, Wins: genData.Wins, Improvements: genData.Improvements,
- Formula: genData.Formula, PostTemplate: genData.PostTemplate,
- HookPattern: genData.HookPattern, Structure: genData.Structure,
- ReplicationTips: genData.ReplicationTips, Avoid: genData.Avoid,
- }
- } else {
- analysis = libformula.FromOwnPostReview(&libownpost.PostFormulaReview{
- Summary: review.Summary, Wins: review.Wins, Improvements: review.Improvements,
- Formula: review.Formula, PostTemplate: review.PostTemplate,
- HookPattern: review.HookPattern, Structure: review.Structure,
- ReplicationTips: review.ReplicationTips, Avoid: review.Avoid,
- })
- }
- postText = strings.TrimSpace(req.PostText)
- if postText == "" && review != nil {
- postText = review.PostText
- }
- sourceRef = mediaID
- case cfentity.SourceScanPost:
- scanPostID := strings.TrimSpace(req.ScanPostID)
- personaID := strings.TrimSpace(req.PersonaID)
- if scanPostID == "" || personaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("scan_post_id and persona_id are required for scan_post")
- }
- post, perr := l.svcCtx.ScanPost.GetForPersona(l.ctx, tenantID, uid, personaID, scanPostID)
- if perr != nil {
- return nil, perr
- }
- postText = post.Text
- author = post.Author
- permalink = post.Permalink
- analysis, err = l.analyzeViralPost(aiReq, post.Text, post.Author, post.LikeCount, post.ReplyCount, post.SearchTag, personaBlock)
- sourceRef = scanPostID
- metrics = &cfentity.SourceMetrics{LikeCount: post.LikeCount, ReplyCount: post.ReplyCount}
- case cfentity.SourceKeywordSearch:
- postText = strings.TrimSpace(req.PostText)
- if postText == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("post_text is required after selecting a search result")
- }
- author = strings.TrimSpace(req.AuthorName)
- permalink = strings.TrimSpace(req.Permalink)
- analysis, err = l.analyzeViralPost(aiReq, postText, author, req.LikeCount, req.ReplyCount, req.Keyword, personaBlock)
- sourceRef = strings.TrimSpace(req.Keyword)
- metrics = &cfentity.SourceMetrics{
- LikeCount: req.LikeCount, ReplyCount: req.ReplyCount,
- Views: req.Views, RepostCount: req.RepostCount,
- QuoteCount: req.QuoteCount, Shares: req.Shares,
- }
- default:
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("unsupported source_type")
- }
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("分析失敗:" + err.Error())
- }
- if analysis == nil {
- return nil, app.For(code.AI).SvcThirdParty("分析未產出有效公式")
- }
-
- label := strings.TrimSpace(req.SaveLabel)
- if label == "" {
- label = defaultFormulaLabel(sourceType, postText, author)
- }
- saved, err := l.svcCtx.ContentFormula.Create(l.ctx, cfdom.CreateRequest{
- TenantID: tenantID, OwnerUID: uid, AccountID: req.ID,
- Label: label, SourceType: sourceType, SourceRef: sourceRef,
- SourcePostText: postText, SourceAuthor: author, SourcePermalink: permalink,
- SourceMetrics: metrics,
- Summary: analysis.Summary, Wins: analysis.Wins, Improvements: analysis.Improvements,
- Formula: analysis.Formula, PostTemplate: analysis.PostTemplate,
- HookPattern: analysis.HookPattern, Structure: analysis.Structure,
- ReplicationTips: analysis.ReplicationTips, Avoid: analysis.Avoid,
- })
- if err != nil {
- return nil, err
- }
- data := toContentFormulaData(*saved)
- return &types.AnalyzeContentFormulaData{
- Formula: data,
- Message: fmt.Sprintf("已分析並存入公式庫:%s", data.Label),
- }, nil
-}
-
-func (l *AnalyzeContentFormulaLogic) resolvePersonaBlock(tenantID, uid, personaID string) string {
- personaID = strings.TrimSpace(personaID)
- if personaID == "" {
- return ""
- }
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return ""
- }
- return liboutreach.FormatVoicePersonaBlock(persona)
-}
-
-func (l *AnalyzeContentFormulaLogic) analyzeViralPost(
- aiReq domai.GenerateRequest,
- postText, author string, likes, replies int, tag, personaBlock string,
-) (*libformula.PastedAnalysis, error) {
- out, err := l.svcCtx.AI.GenerateText(l.ctx, domai.GenerateRequest{
- Provider: aiReq.Provider, Model: aiReq.Model, Credential: aiReq.Credential,
- System: libviral.BuildAnalyzeViralSystemPrompt(),
- Messages: []domai.Message{{Role: "user", Content: libviral.BuildAnalyzeViralUserPrompt(libviral.AnalyzeViralInput{
- PostText: postText, AuthorName: author, LikeCount: likes, ReplyCount: replies,
- SearchTag: tag, TopicLabel: tag, Persona: personaBlock,
- })}},
- })
- if err != nil {
- return nil, err
- }
- parsed, err := libviral.ParseAnalyzeViralOutput(out.Text)
- if err != nil {
- return nil, err
- }
- return libformula.FromViralAnalysis(
- parsed.HookPattern, parsed.StructurePattern, parsed.EmotionalTrigger,
- parsed.ReplicationStrategy, parsed.KeyTakeaways,
- ), nil
-}
-
-func defaultFormulaLabel(sourceType, postText, author string) string {
- preview := strings.TrimSpace(postText)
- if len([]rune(preview)) > 24 {
- preview = string([]rune(preview)[:24]) + "…"
- }
- if author != "" {
- return fmt.Sprintf("%s · @%s", sourceType, author)
- }
- if preview != "" {
- return preview
- }
- return sourceType + " 公式"
-}
diff --git a/old/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go b/old/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go
deleted file mode 100644
index 5059d0d..0000000
--- a/old/backend/internal/logic/threads_account/cancel_publish_queue_item_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type CancelPublishQueueItemLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCancelPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelPublishQueueItemLogic {
- return &CancelPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CancelPublishQueueItemLogic) CancelPublishQueueItem(req *types.PublishQueueItemPath) (*types.PublishQueueItemData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.Cancel(l.ctx, tenantID, uid, req.ID, req.QID)
- if err != nil {
- return nil, err
- }
- return toPublishQueueItemData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/content_formula_mapper.go b/old/backend/internal/logic/threads_account/content_formula_mapper.go
deleted file mode 100644
index 2103b2e..0000000
--- a/old/backend/internal/logic/threads_account/content_formula_mapper.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package threads_account
-
-import (
- cfentity "haixun-backend/internal/model/content_formula/domain/entity"
- cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toContentFormulaData(item cfdom.FormulaSummary) types.ContentFormulaData {
- var metrics *types.ContentFormulaSourceMetricsData
- if item.SourceMetrics != nil {
- metrics = &types.ContentFormulaSourceMetricsData{
- LikeCount: item.SourceMetrics.LikeCount,
- ReplyCount: item.SourceMetrics.ReplyCount,
- Views: item.SourceMetrics.Views,
- RepostCount: item.SourceMetrics.RepostCount,
- QuoteCount: item.SourceMetrics.QuoteCount,
- Shares: item.SourceMetrics.Shares,
- }
- }
- return types.ContentFormulaData{
- ID: item.ID,
- AccountID: item.AccountID,
- Label: item.Label,
- SourceType: item.SourceType,
- SourceRef: item.SourceRef,
- SourcePostText: item.SourcePostText,
- SourceAuthor: item.SourceAuthor,
- SourcePermalink: item.SourcePermalink,
- SourceMetrics: metrics,
- Summary: item.Summary,
- Wins: item.Wins,
- Improvements: item.Improvements,
- Formula: item.Formula,
- PostTemplate: item.PostTemplate,
- HookPattern: item.HookPattern,
- Structure: item.Structure,
- ReplicationTips: item.ReplicationTips,
- Avoid: item.Avoid,
- Tags: item.Tags,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toSourceMetrics(data *types.ContentFormulaSourceMetricsData) *cfentity.SourceMetrics {
- if data == nil {
- return nil
- }
- return &cfentity.SourceMetrics{
- LikeCount: data.LikeCount,
- ReplyCount: data.ReplyCount,
- Views: data.Views,
- RepostCount: data.RepostCount,
- QuoteCount: data.QuoteCount,
- Shares: data.Shares,
- }
-}
diff --git a/old/backend/internal/logic/threads_account/create_publish_queue_item_logic.go b/old/backend/internal/logic/threads_account/create_publish_queue_item_logic.go
deleted file mode 100644
index 74458bf..0000000
--- a/old/backend/internal/logic/threads_account/create_publish_queue_item_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type CreatePublishQueueItemLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreatePublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreatePublishQueueItemLogic {
- return &CreatePublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CreatePublishQueueItemLogic) CreatePublishQueueItem(req *types.CreatePublishQueueHandlerReq) (*types.PublishQueueItemData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.Create(l.ctx, pqdomain.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- Text: req.Text,
- ImageKey: req.ImageKey,
- ImageKeys: req.ImageKeys,
- TopicTag: req.TopicTag,
- ScheduledAt: req.ScheduledAt,
- })
- if err != nil {
- return nil, err
- }
- return toPublishQueueItemData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/create_threads_account_logic.go b/old/backend/internal/logic/threads_account/create_threads_account_logic.go
deleted file mode 100644
index 5cbfd76..0000000
--- a/old/backend/internal/logic/threads_account/create_threads_account_logic.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type CreateThreadsAccountLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewCreateThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateThreadsAccountLogic {
- return &CreateThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *CreateThreadsAccountLogic) CreateThreadsAccount(req *types.CreateThreadsAccountReq) (*types.ThreadsAccountData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- activate := true
- displayName := ""
- if req != nil {
- if req.Activate != nil {
- activate = *req.Activate
- }
- displayName = req.DisplayName
- }
- item, err := l.svcCtx.ThreadsAccount.Create(l.ctx, domusecase.CreateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- DisplayName: displayName,
- Activate: activate,
- })
- if err != nil {
- return nil, err
- }
- out := toThreadsAccountData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/threads_account/delete_content_formula_logic.go b/old/backend/internal/logic/threads_account/delete_content_formula_logic.go
deleted file mode 100644
index b6b4af5..0000000
--- a/old/backend/internal/logic/threads_account/delete_content_formula_logic.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package threads_account
-
-import (
- "context"
- "fmt"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DeleteContentFormulaLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteContentFormulaLogic {
- return &DeleteContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeleteContentFormulaLogic) DeleteContentFormula(req *types.GetContentFormulaHandlerReq) (*types.DeleteContentFormulaData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if err := l.svcCtx.ContentFormula.Delete(l.ctx, tenantID, uid, req.ID, req.FormulaID); err != nil {
- return nil, err
- }
- return &types.DeleteContentFormulaData{
- FormulaID: req.FormulaID,
- Message: fmt.Sprintf("已刪除公式 %s", req.FormulaID),
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go b/old/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go
deleted file mode 100644
index e9d9529..0000000
--- a/old/backend/internal/logic/threads_account/delete_publish_queue_item_logic.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DeletePublishQueueItemLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeletePublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeletePublishQueueItemLogic {
- return &DeletePublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeletePublishQueueItemLogic) DeletePublishQueueItem(req *types.PublishQueueItemPath) error {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return err
- }
- return l.svcCtx.PublishQueue.Delete(l.ctx, tenantID, uid, req.ID, req.QID)
-}
diff --git a/old/backend/internal/logic/threads_account/delete_threads_account_logic.go b/old/backend/internal/logic/threads_account/delete_threads_account_logic.go
deleted file mode 100644
index 296aec4..0000000
--- a/old/backend/internal/logic/threads_account/delete_threads_account_logic.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DeleteThreadsAccountLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDeleteThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteThreadsAccountLogic {
- return &DeleteThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DeleteThreadsAccountLogic) DeleteThreadsAccount(req *types.ThreadsAccountPath) (*types.DeleteThreadsAccountData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if err := l.svcCtx.ThreadsAccount.Delete(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- return &types.DeleteThreadsAccountData{
- DeletedID: req.ID,
- ActiveAccountID: accounts.ActiveAccountID,
- Message: "帳號已刪除",
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/disconnect_threads_api_logic.go b/old/backend/internal/logic/threads_account/disconnect_threads_api_logic.go
deleted file mode 100644
index 44c486a..0000000
--- a/old/backend/internal/logic/threads_account/disconnect_threads_api_logic.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type DisconnectThreadsApiLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewDisconnectThreadsApiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisconnectThreadsApiLogic {
- return &DisconnectThreadsApiLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *DisconnectThreadsApiLogic) DisconnectThreadsApi(req *types.ThreadsAccountPath) (*types.ThreadsAccountData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ThreadsAccount.DisconnectThreadsAPI(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- out := toThreadsAccountData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/threads_account/generate_own_post_formula_logic.go b/old/backend/internal/logic/threads_account/generate_own_post_formula_logic.go
deleted file mode 100644
index fb60458..0000000
--- a/old/backend/internal/logic/threads_account/generate_own_post_formula_logic.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package threads_account
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- liboutreach "haixun-backend/internal/library/outreach"
- libownpost "haixun-backend/internal/library/ownpost"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GenerateOwnPostFormulaLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGenerateOwnPostFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostFormulaLogic {
- return &GenerateOwnPostFormulaLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GenerateOwnPostFormulaLogic) GenerateOwnPostFormula(
- req *types.GenerateOwnPostFormulaHandlerReq,
-) (*types.GenerateOwnPostFormulaData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if req.MediaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required")
- }
-
- account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
-
- if !req.Force {
- if cached, cerr := l.svcCtx.OwnPostFormula.Get(l.ctx, tenantID, uid, req.ID, req.MediaID); cerr == nil && cached != nil {
- data := toOwnPostFormulaReviewData(cached)
- data.FromCache = true
- return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil
- }
- }
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- personaBlock := ""
- if personaID := strings.TrimSpace(req.PersonaID); personaID != "" {
- persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if perr != nil {
- return nil, perr
- }
- personaBlock = liboutreach.FormatVoicePersonaBlock(persona)
- } else if personaID = strings.TrimSpace(account.PersonaID); personaID != "" {
- persona, perr := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if perr == nil {
- personaBlock = liboutreach.FormatVoicePersonaBlock(persona)
- }
- }
-
- accountName := account.DisplayName
- if accountName == "" {
- accountName = account.Username
- }
-
- review, err := libownpost.GeneratePostFormula(l.ctx, l.svcCtx.AI, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- }, libownpost.PostFormulaInput{
- AccountName: accountName,
- PersonaBlock: personaBlock,
- PostText: req.PostText,
- TopicTag: req.TopicTag,
- MediaType: req.MediaType,
- LikeCount: req.LikeCount,
- ReplyCount: req.ReplyCount,
- Views: req.Views,
- RepostCount: req.RepostCount,
- QuoteCount: req.QuoteCount,
- Shares: req.Shares,
- Timestamp: req.Timestamp,
- })
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("AI 貼文覆盤失敗:" + err.Error())
- }
- saved, err := l.svcCtx.OwnPostFormula.SaveGenerated(
- l.ctx,
- tenantID,
- uid,
- req.ID,
- req.MediaID,
- strings.TrimSpace(req.PersonaID),
- req.PostText,
- review,
- )
- if err != nil {
- return nil, err
- }
- data := toOwnPostFormulaReviewData(saved)
- data.FromCache = false
- return &types.GenerateOwnPostFormulaData{OwnPostFormulaReviewData: data}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go b/old/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go
deleted file mode 100644
index fa36120..0000000
--- a/old/backend/internal/logic/threads_account/generate_own_post_reply_draft_logic.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package threads_account
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- liboutreach "haixun-backend/internal/library/outreach"
- libownpost "haixun-backend/internal/library/ownpost"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GenerateOwnPostReplyDraftLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGenerateOwnPostReplyDraftLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateOwnPostReplyDraftLogic {
- return &GenerateOwnPostReplyDraftLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GenerateOwnPostReplyDraftLogic) GenerateOwnPostReplyDraft(
- req *types.GenerateOwnPostReplyDraftHandlerReq,
-) (*types.GenerateOwnPostReplyDraftData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if req.MediaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("media_id is required")
- }
-
- account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
-
- credential, err := l.svcCtx.ThreadsAccount.ResolveMemberAiCredential(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return nil, err
- }
-
- personaID := strings.TrimSpace(req.PersonaID)
- if personaID == "" {
- personaID = strings.TrimSpace(account.PersonaID)
- }
- if personaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先選擇人設(persona_id)")
- }
- persona, err := l.svcCtx.Persona.Get(l.ctx, tenantID, uid, personaID)
- if err != nil {
- return nil, err
- }
- personaBlock := liboutreach.FormatVoicePersonaBlock(persona)
- if strings.TrimSpace(personaBlock) == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("所選人設尚未填寫語氣或 8D 風格,請先到人設頁補齊")
- }
-
- accountName := account.DisplayName
- if accountName == "" {
- accountName = account.Username
- }
-
- text, err := libownpost.GenerateReplyDraft(l.ctx, l.svcCtx.AI, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- }, libownpost.ReplyDraftInput{
- AccountName: accountName,
- PersonaBlock: personaBlock,
- PostText: req.PostText,
- ReplyText: req.ReplyText,
- ReplyToID: req.ReplyToID,
- ThreadPostText: req.ThreadPostText,
- ParentReplyText: req.ParentReplyText,
- })
- if err != nil {
- return nil, app.For(code.AI).SvcThirdParty("AI 回覆草稿生成失敗:" + err.Error())
- }
- return &types.GenerateOwnPostReplyDraftData{Text: text}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_content_formula_logic.go b/old/backend/internal/logic/threads_account/get_content_formula_logic.go
deleted file mode 100644
index 31adef3..0000000
--- a/old/backend/internal/logic/threads_account/get_content_formula_logic.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetContentFormulaLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetContentFormulaLogic {
- return &GetContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetContentFormulaLogic) GetContentFormula(req *types.GetContentFormulaHandlerReq) (*types.ContentFormulaData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentFormula.Get(l.ctx, tenantID, uid, req.ID, req.FormulaID)
- if err != nil {
- return nil, err
- }
- data := toContentFormulaData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go b/old/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go
deleted file mode 100644
index da4d205..0000000
--- a/old/backend/internal/logic/threads_account/get_publish_dashboard_summary_logic.go
+++ /dev/null
@@ -1,72 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPublishDashboardSummaryLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPublishDashboardSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishDashboardSummaryLogic {
- return &GetPublishDashboardSummaryLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetPublishDashboardSummaryLogic) GetPublishDashboardSummary() (resp *types.PublishDashboardSummaryData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- accounts, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- resp = &types.PublishDashboardSummaryData{List: []types.PublishDashboardAccountSummary{}}
- for _, account := range accounts.List {
- health, hErr := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, account.ID, 1, 10)
- if hErr != nil {
- continue
- }
- guard, _ := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, account.ID)
- slots, _ := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, account.ID, "Asia/Taipei")
- bestSlot, lowSlot := "", ""
- for _, slot := range slots {
- if bestSlot == "" && slot.Recommendation == "recommended" {
- bestSlot = slot.Time
- }
- if lowSlot == "" && slot.Recommendation == "low" {
- lowSlot = slot.Time
- }
- }
- row := types.PublishDashboardAccountSummary{
- AccountID: account.ID, AccountName: firstNonEmpty(account.DisplayName, account.Username, account.ID),
- PendingScheduled: health.Summary.PendingScheduled, FailedCount: health.Summary.FailedCount,
- Published7d: health.Summary.Published7d, TotalLikes7d: health.Summary.TotalLikes7d,
- TotalReplies7d: health.Summary.TotalReplies7d, BestSlot: bestSlot, LowSlot: lowSlot,
- }
- if guard != nil {
- row.Paused = guard.Paused
- }
- resp.List = append(resp.List, row)
- resp.TotalPending += row.PendingScheduled
- resp.TotalFailed += row.FailedCount
- resp.TotalPublished7d += row.Published7d
- resp.TotalLikes7d += row.TotalLikes7d
- resp.TotalReplies7d += row.TotalReplies7d
- }
- return resp, nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go b/old/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go
deleted file mode 100644
index 6e15f0b..0000000
--- a/old/backend/internal/logic/threads_account/get_publish_guard_policy_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPublishGuardPolicyLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishGuardPolicyLogic {
- return &GetPublishGuardPolicyLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetPublishGuardPolicyLogic) GetPublishGuardPolicy(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PublishGuard.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toGuardPolicyData(item), nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go b/old/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go
deleted file mode 100644
index cde2e9e..0000000
--- a/old/backend/internal/logic/threads_account/get_publish_inventory_policy_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPublishInventoryPolicyLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishInventoryPolicyLogic {
- return &GetPublishInventoryPolicyLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetPublishInventoryPolicyLogic) GetPublishInventoryPolicy(req *types.ThreadsAccountPath) (resp *types.PublishInventoryPolicyData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toInventoryPolicyData(item), nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_publish_queue_item_logic.go b/old/backend/internal/logic/threads_account/get_publish_queue_item_logic.go
deleted file mode 100644
index ee2efec..0000000
--- a/old/backend/internal/logic/threads_account/get_publish_queue_item_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetPublishQueueItemLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishQueueItemLogic {
- return &GetPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetPublishQueueItemLogic) GetPublishQueueItem(req *types.PublishQueueItemPath) (*types.PublishQueueItemData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.Get(l.ctx, tenantID, uid, req.ID, req.QID)
- if err != nil {
- return nil, err
- }
- return toPublishQueueItemData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go b/old/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go
deleted file mode 100644
index 7b84ba3..0000000
--- a/old/backend/internal/logic/threads_account/get_publish_slot_insights_logic.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetPublishSlotInsightsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetPublishSlotInsightsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPublishSlotInsightsLogic {
- return &GetPublishSlotInsightsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetPublishSlotInsightsLogic) GetPublishSlotInsights(req *types.ThreadsAccountPath) (resp *types.PublishSlotInsightsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- policy, _ := l.svcCtx.PublishInventory.Get(l.ctx, tenantID, uid, req.ID)
- timezone := "Asia/Taipei"
- if policy != nil && policy.Timezone != "" {
- timezone = policy.Timezone
- }
- items, err := l.svcCtx.PublishQueue.ListSlotInsights(l.ctx, tenantID, uid, req.ID, timezone)
- if err != nil {
- return nil, err
- }
- list := make([]types.PublishSlotInsightData, 0, len(items))
- for _, item := range items {
- list = append(list, types.PublishSlotInsightData{
- Weekday: item.Weekday, Time: item.Time, AvgLikes1h: item.AvgLikes1h, AvgReplies1h: item.AvgReplies1h,
- AvgLikes24h: item.AvgLikes24h, AvgReplies24h: item.AvgReplies24h,
- AvgLikes7d: item.AvgLikes7d, AvgReplies7d: item.AvgReplies7d,
- SampleSize: item.SampleSize, Recommendation: item.Recommendation,
- })
- }
- return &types.PublishSlotInsightsData{AccountID: req.ID, Timezone: timezone, Slots: list}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_threads_account_ai_settings_logic.go b/old/backend/internal/logic/threads_account/get_threads_account_ai_settings_logic.go
deleted file mode 100644
index 4fae47c..0000000
--- a/old/backend/internal/logic/threads_account/get_threads_account_ai_settings_logic.go
+++ /dev/null
@@ -1,59 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetThreadsAccountAiSettingsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetThreadsAccountAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsAccountAiSettingsLogic {
- return &GetThreadsAccountAiSettingsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetThreadsAccountAiSettingsLogic) GetThreadsAccountAiSettings(req *types.ThreadsAccountPath) (resp *types.ThreadsAccountAiSettingsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- data, err := l.svcCtx.ThreadsAccount.GetAiSettings(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toAiSettingsData(data), nil
-}
-
-func toAiSettingsData(data *domusecase.AiSettings) *types.ThreadsAccountAiSettingsData {
- if data == nil {
- return nil
- }
- configured := map[string]interface{}{}
- for provider, ok := range data.ApiKeysConfigured {
- configured[provider] = ok
- }
- return &types.ThreadsAccountAiSettingsData{
- AccountID: data.AccountID,
- Provider: data.Provider,
- Model: data.Model,
- ResearchProvider: data.ResearchProvider,
- ResearchModel: data.ResearchModel,
- ApiKeys: data.ApiKeys,
- ApiKeysConfigured: configured,
- }
-}
diff --git a/old/backend/internal/logic/threads_account/get_threads_account_connection_logic.go b/old/backend/internal/logic/threads_account/get_threads_account_connection_logic.go
deleted file mode 100644
index 5f2f35a..0000000
--- a/old/backend/internal/logic/threads_account/get_threads_account_connection_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetThreadsAccountConnectionLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetThreadsAccountConnectionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsAccountConnectionLogic {
- return &GetThreadsAccountConnectionLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetThreadsAccountConnectionLogic) GetThreadsAccountConnection(req *types.ThreadsAccountPath) (resp *types.ThreadsAccountConnectionData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- data, err := l.svcCtx.ThreadsAccount.GetConnection(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toConnectionData(data), nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_threads_account_logic.go b/old/backend/internal/logic/threads_account/get_threads_account_logic.go
deleted file mode 100644
index 0cdeda4..0000000
--- a/old/backend/internal/logic/threads_account/get_threads_account_logic.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetThreadsAccountLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsAccountLogic {
- return &GetThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *GetThreadsAccountLogic) GetThreadsAccount(req *types.ThreadsAccountPath) (*types.ThreadsAccountData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- out := toThreadsAccountData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go b/old/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go
deleted file mode 100644
index c4a23d5..0000000
--- a/old/backend/internal/logic/threads_account/get_threads_diagnostics_logic.go
+++ /dev/null
@@ -1,55 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type GetThreadsDiagnosticsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewGetThreadsDiagnosticsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetThreadsDiagnosticsLogic {
- return &GetThreadsDiagnosticsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *GetThreadsDiagnosticsLogic) GetThreadsDiagnostics(req *types.ThreadsAccountPath) (resp *types.ThreadsDiagnosticsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- smoke, smokeErr := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID)
- items := make([]types.ThreadsAPISmokeTestItem, 0, len(smoke))
- for _, item := range smoke {
- items = append(items, types.ThreadsAPISmokeTestItem{
- Scope: item.Scope, Name: item.Name, Status: item.Status, Message: item.Message, Count: item.Count,
- })
- }
- message := "診斷完成"
- if smokeErr != nil {
- message = smokeErr.Error()
- }
- return &types.ThreadsDiagnosticsData{
- AccountID: req.ID, CheckedAt: clock.NowUnixNano(), ApiConnected: account.ApiConnected,
- TokenExpiresAt: account.APITokenExpiresAt, Scopes: libthreads.DefaultOAuthScopes, Items: items, Message: message,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go b/old/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go
deleted file mode 100644
index 162b872..0000000
--- a/old/backend/internal/logic/threads_account/get_threads_oauth_config_logic.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package threads_account
-
-import (
- "strings"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type GetThreadsOAuthConfigLogic struct {
- svcCtx *svc.ServiceContext
-}
-
-func NewGetThreadsOAuthConfigLogic(svcCtx *svc.ServiceContext) *GetThreadsOAuthConfigLogic {
- return &GetThreadsOAuthConfigLogic{svcCtx: svcCtx}
-}
-
-func (l *GetThreadsOAuthConfigLogic) GetThreadsOAuthConfig() *types.ThreadsOAuthConfigData {
- cfg := l.svcCtx.Config.ThreadsOAuth
- redirect := strings.TrimSpace(cfg.RedirectURI)
- return &types.ThreadsOAuthConfigData{
- RedirectURI: redirect,
- SuccessRedirect: strings.TrimSpace(cfg.SuccessRedirect),
- Enabled: strings.TrimSpace(cfg.AppID) != "" && strings.TrimSpace(cfg.AppSecret) != "" && redirect != "",
- RequiresHTTPS: !strings.HasPrefix(strings.ToLower(redirect), "https://"),
- }
-}
diff --git a/old/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go b/old/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go
deleted file mode 100644
index fde6ef1..0000000
--- a/old/backend/internal/logic/threads_account/import_own_post_to_content_formula_logic.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- cfentity "haixun-backend/internal/model/content_formula/domain/entity"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ImportOwnPostToContentFormulaLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewImportOwnPostToContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ImportOwnPostToContentFormulaLogic {
- return &ImportOwnPostToContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ImportOwnPostToContentFormulaLogic) ImportOwnPostToContentFormula(req *types.ImportOwnPostFormulaHandlerReq) (*types.AnalyzeContentFormulaData, error) {
- analyze := NewAnalyzeContentFormulaLogic(l.ctx, l.svcCtx)
- return analyze.AnalyzeContentFormula(&types.AnalyzeContentFormulaHandlerReq{
- ThreadsAccountPath: types.ThreadsAccountPath{ID: req.ID},
- AnalyzeContentFormulaReq: types.AnalyzeContentFormulaReq{
- SourceType: cfentity.SourceOwnPost,
- MediaID: req.MediaID,
- SaveLabel: req.Label,
- },
- })
-}
diff --git a/old/backend/internal/logic/threads_account/import_threads_account_session_logic.go b/old/backend/internal/logic/threads_account/import_threads_account_session_logic.go
deleted file mode 100644
index f751bd2..0000000
--- a/old/backend/internal/logic/threads_account/import_threads_account_session_logic.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-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,
- }
-}
-
-func (l *ImportThreadsAccountSessionLogic) ImportThreadsAccountSession(req *types.ImportThreadsAccountSessionHandlerReq) (resp *types.ImportThreadsAccountSessionData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.ThreadsAccount.ImportBrowserSession(l.ctx, domusecase.ImportBrowserSessionRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- StorageState: req.StorageState,
- })
- if err != nil {
- return nil, err
- }
- return &types.ImportThreadsAccountSessionData{
- Success: true,
- Valid: result.Valid,
- Synced: result.Synced,
- AccountID: result.AccountID,
- Username: result.Username,
- Message: result.Message,
- UpdateAt: result.UpdateAt,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_content_formulas_logic.go b/old/backend/internal/logic/threads_account/list_content_formulas_logic.go
deleted file mode 100644
index 6399e96..0000000
--- a/old/backend/internal/logic/threads_account/list_content_formulas_logic.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package threads_account
-
-import (
- "context"
- "math"
-
- cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListContentFormulasLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListContentFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListContentFormulasLogic {
- return &ListContentFormulasLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListContentFormulasLogic) ListContentFormulas(req *types.ListContentFormulasHandlerReq) (*types.ListContentFormulasData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- page := int(req.Page)
- if page <= 0 {
- page = 1
- }
- pageSize := int(req.PageSize)
- if pageSize <= 0 {
- pageSize = 20
- }
- result, err := l.svcCtx.ContentFormula.List(l.ctx, cfdom.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- SourceType: req.SourceType,
- Tag: req.Tag,
- Page: page,
- PageSize: pageSize,
- })
- if err != nil {
- return nil, err
- }
- list := make([]types.ContentFormulaData, 0, len(result.Items))
- for _, item := range result.Items {
- list = append(list, toContentFormulaData(item))
- }
- totalPages := int64(0)
- if pageSize > 0 {
- totalPages = int64(math.Ceil(float64(result.Total) / float64(pageSize)))
- }
- return &types.ListContentFormulasData{
- Pagination: types.PaginationData{
- Total: result.Total,
- Page: int64(page),
- PageSize: int64(pageSize),
- TotalPages: totalPages,
- },
- List: list,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_mention_inbox_logic.go b/old/backend/internal/logic/threads_account/list_mention_inbox_logic.go
deleted file mode 100644
index 9ce78d1..0000000
--- a/old/backend/internal/logic/threads_account/list_mention_inbox_logic.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListMentionInboxLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMentionInboxLogic {
- return &ListMentionInboxLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListMentionInboxLogic) ListMentionInbox(req *types.ListMentionInboxHandlerReq) (*types.ListMentionInboxData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.MentionInbox.List(l.ctx, mentioninboxdomain.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- Page: req.Page,
- PageSize: req.PageSize,
- })
- if err != nil {
- return nil, err
- }
- return &types.ListMentionInboxData{
- List: toMentionInboxItems(result.List),
- Pagination: types.PaginationData{
- Total: result.Pagination.Total,
- Page: result.Pagination.Page,
- PageSize: result.Pagination.PageSize,
- TotalPages: result.Pagination.TotalPages,
- },
- ReadyTotal: result.Pagination.ReadyTotal,
- NewestSyncedAt: result.Pagination.NewestSyncedAt,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_own_post_formulas_logic.go b/old/backend/internal/logic/threads_account/list_own_post_formulas_logic.go
deleted file mode 100644
index 23c3be3..0000000
--- a/old/backend/internal/logic/threads_account/list_own_post_formulas_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListOwnPostFormulasLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListOwnPostFormulasLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListOwnPostFormulasLogic {
- return &ListOwnPostFormulasLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListOwnPostFormulasLogic) ListOwnPostFormulas(req *types.ListOwnPostFormulasHandlerReq) (*types.ListOwnPostFormulasData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- items, err := l.svcCtx.OwnPostFormula.ListByAccount(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- list := make([]types.OwnPostFormulaReviewData, 0, len(items))
- for i := range items {
- list = append(list, toOwnPostFormulaReviewData(&items[i]))
- }
- return &types.ListOwnPostFormulasData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_publish_alerts_logic.go b/old/backend/internal/logic/threads_account/list_publish_alerts_logic.go
deleted file mode 100644
index 1d239c2..0000000
--- a/old/backend/internal/logic/threads_account/list_publish_alerts_logic.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListPublishAlertsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPublishAlertsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishAlertsLogic {
- return &ListPublishAlertsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListPublishAlertsLogic) ListPublishAlerts(req *types.ThreadsAccountPath) (resp *types.PublishAlertsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.PublishQueue.ListAlerts(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- list := make([]types.PublishAlertData, 0, len(items))
- for _, item := range items {
- list = append(list, types.PublishAlertData{
- Type: item.Type, Severity: item.Severity, Message: item.Message,
- QueueID: item.QueueID, AccountID: item.AccountID, CreateAt: item.CreateAt,
- })
- }
- return &types.PublishAlertsData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_publish_queue_events_logic.go b/old/backend/internal/logic/threads_account/list_publish_queue_events_logic.go
deleted file mode 100644
index 327998a..0000000
--- a/old/backend/internal/logic/threads_account/list_publish_queue_events_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListPublishQueueEventsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPublishQueueEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueEventsLogic {
- return &ListPublishQueueEventsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListPublishQueueEventsLogic) ListPublishQueueEvents(req *types.PublishQueueItemPath) (resp *types.PublishQueueEventsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.PublishQueueEvent.ListByQueue(l.ctx, tenantID, uid, req.ID, req.QID, 100)
- if err != nil {
- return nil, err
- }
- return &types.PublishQueueEventsData{List: toQueueEventData(items)}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_publish_queue_items_logic.go b/old/backend/internal/logic/threads_account/list_publish_queue_items_logic.go
deleted file mode 100644
index 020661d..0000000
--- a/old/backend/internal/logic/threads_account/list_publish_queue_items_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListPublishQueueItemsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPublishQueueItemsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueItemsLogic {
- return &ListPublishQueueItemsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListPublishQueueItemsLogic) ListPublishQueueItems(req *types.ListPublishQueueHandlerReq) (*types.PublishQueueListData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.List(l.ctx, pqdomain.ListRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- Status: req.Status,
- Page: req.Page,
- PageSize: req.PageSize,
- })
- if err != nil {
- return nil, err
- }
- return toPublishQueueListData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_publish_queue_range_logic.go b/old/backend/internal/logic/threads_account/list_publish_queue_range_logic.go
deleted file mode 100644
index db34dbc..0000000
--- a/old/backend/internal/logic/threads_account/list_publish_queue_range_logic.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListPublishQueueRangeLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListPublishQueueRangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListPublishQueueRangeLogic {
- return &ListPublishQueueRangeLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListPublishQueueRangeLogic) ListPublishQueueRange(req *types.PublishQueueRangeHandlerReq) (resp *types.PublishQueueListData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.ListRange(l.ctx, pqdomain.RangeRequest{
- TenantID: tenantID, OwnerUID: uid, AccountID: req.ID,
- Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt, Page: 1, PageSize: 200,
- })
- if err != nil {
- return nil, err
- }
- return toPublishQueueListData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go b/old/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go
deleted file mode 100644
index 41556bb..0000000
--- a/old/backend/internal/logic/threads_account/list_threads_account_ai_provider_models_logic.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/model/ai/domain/enum"
- aiuc "haixun-backend/internal/model/ai/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListThreadsAccountAiProviderModelsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListThreadsAccountAiProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsAccountAiProviderModelsLogic {
- return &ListThreadsAccountAiProviderModelsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListThreadsAccountAiProviderModelsLogic) ListThreadsAccountAiProviderModels(
- req *types.ThreadsAccountAiProviderModelsHandlerReq,
-) (*types.ThreadsAccountAiProviderModelsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- apiKey, err := l.svcCtx.ThreadsAccount.ResolveAiProviderAPIKey(
- l.ctx,
- tenantID,
- uid,
- req.ID,
- req.Provider,
- req.ApiKey,
- )
- if err != nil {
- return nil, err
- }
- result := l.svcCtx.AI.ListProviderModels(
- l.ctx,
- enum.ProviderID(req.Provider),
- aiuc.Credential{APIKey: apiKey},
- )
- return &types.ThreadsAccountAiProviderModelsData{
- ID: result.ID,
- Label: result.Label,
- Models: result.Models,
- Streams: result.Streams,
- Error: result.Error,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_threads_accounts_logic.go b/old/backend/internal/logic/threads_account/list_threads_accounts_logic.go
deleted file mode 100644
index 52976eb..0000000
--- a/old/backend/internal/logic/threads_account/list_threads_accounts_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListThreadsAccountsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListThreadsAccountsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsAccountsLogic {
- return &ListThreadsAccountsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListThreadsAccountsLogic) ListThreadsAccounts() (*types.ListThreadsAccountsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.ThreadsAccount.List(l.ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- return toListData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go b/old/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go
deleted file mode 100644
index ebad032..0000000
--- a/old/backend/internal/logic/threads_account/list_threads_oauth_logs_logic.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListThreadsOAuthLogsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListThreadsOAuthLogsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsOAuthLogsLogic {
- return &ListThreadsOAuthLogsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListThreadsOAuthLogsLogic) ListThreadsOAuthLogs() (*types.ListThreadsOAuthLogsData, error) {
- items := l.svcCtx.ThreadsAccount.ListOAuthDebugLogs(50)
- list := make([]types.ThreadsOAuthLogItem, 0, len(items))
- for _, item := range items {
- list = append(list, types.ThreadsOAuthLogItem{
- At: item.At,
- Level: item.Level,
- Stage: item.Stage,
- AccountID: item.AccountID,
- Message: item.Message,
- })
- }
- return &types.ListThreadsOAuthLogsData{List: list}, nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_threads_post_performance_logic.go b/old/backend/internal/logic/threads_account/list_threads_post_performance_logic.go
deleted file mode 100644
index 6153768..0000000
--- a/old/backend/internal/logic/threads_account/list_threads_post_performance_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ListThreadsPostPerformanceLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListThreadsPostPerformanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsPostPerformanceLogic {
- return &ListThreadsPostPerformanceLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ListThreadsPostPerformanceLogic) ListThreadsPostPerformance(req *types.ListThreadsPostPerformanceHandlerReq) (*types.ThreadsPostPerformanceData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.ThreadsAccount.ListPostPerformance(l.ctx, tenantID, uid, req.ID, req.Limit)
- if err != nil {
- return nil, err
- }
- return toPostPerformanceData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/list_threads_publish_health_logic.go b/old/backend/internal/logic/threads_account/list_threads_publish_health_logic.go
deleted file mode 100644
index db640d6..0000000
--- a/old/backend/internal/logic/threads_account/list_threads_publish_health_logic.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ListThreadsPublishHealthLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewListThreadsPublishHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListThreadsPublishHealthLogic {
- return &ListThreadsPublishHealthLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ListThreadsPublishHealthLogic) ListThreadsPublishHealth(req *types.ListThreadsPublishHealthHandlerReq) (*types.ThreadsPublishHealthData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.ListPublishHealth(l.ctx, tenantID, uid, req.ID, req.Page, req.PageSize)
- if err != nil {
- return nil, err
- }
- return toPublishHealthData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/mapper.go b/old/backend/internal/logic/threads_account/mapper.go
deleted file mode 100644
index e986a51..0000000
--- a/old/backend/internal/logic/threads_account/mapper.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package threads_account
-
-import (
- guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
- inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toThreadsAccountData(item domusecase.AccountSummary) types.ThreadsAccountData {
- return types.ThreadsAccountData{
- ID: item.ID,
- DisplayName: item.DisplayName,
- Username: item.Username,
- ThreadsUserID: item.ThreadsUserID,
- PersonaID: item.PersonaID,
- BrowserConnected: item.BrowserConnected,
- ApiConnected: item.ApiConnected,
- APITokenExpiresAt: item.APITokenExpiresAt,
- Status: item.Status,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toPostPerformanceData(result *domusecase.PostPerformanceResult) *types.ThreadsPostPerformanceData {
- if result == nil {
- return &types.ThreadsPostPerformanceData{Posts: []types.ThreadsPostPerformanceItem{}}
- }
- caps := make([]types.ThreadsPostMetricCapability, 0, len(result.Capabilities))
- for _, item := range result.Capabilities {
- caps = append(caps, types.ThreadsPostMetricCapability{
- Scope: item.Scope, Name: item.Name, Metrics: item.Metrics,
- Trackable: item.Trackable, Note: item.Note,
- })
- }
- metrics := make([]types.ThreadsInsightMetricItem, 0, len(result.AccountInsights.Metrics))
- for _, item := range result.AccountInsights.Metrics {
- metrics = append(metrics, types.ThreadsInsightMetricItem{Name: item.Name, Value: item.Value})
- }
- posts := make([]types.ThreadsPostPerformanceItem, 0, len(result.Posts))
- for _, item := range result.Posts {
- posts = append(posts, types.ThreadsPostPerformanceItem{
- MediaID: item.MediaID, Text: item.Text, Permalink: item.Permalink, Timestamp: item.Timestamp,
- MediaType: item.MediaType, MediaURL: item.MediaURL, ThumbnailURL: item.ThumbnailURL,
- TopicTag: item.TopicTag, Shortcode: item.Shortcode,
- LikeCount: item.LikeCount, ReplyCount: item.ReplyCount, RepostCount: item.RepostCount, QuoteCount: item.QuoteCount,
- Views: item.Views, Shares: item.Shares, InsightsStatus: item.InsightsStatus, InsightsMessage: item.InsightsMessage,
- })
- }
- return &types.ThreadsPostPerformanceData{
- AccountID: result.AccountID, Username: result.Username, ThreadsUserID: result.ThreadsUserID,
- FetchedAt: result.FetchedAt, Capabilities: caps,
- AccountInsights: types.ThreadsAccountInsightsBlock{
- Status: result.AccountInsights.Status, Message: result.AccountInsights.Message, Metrics: metrics,
- },
- Posts: posts,
- }
-}
-
-func toListData(result *domusecase.ListResult) *types.ListThreadsAccountsData {
- if result == nil {
- return &types.ListThreadsAccountsData{List: []types.ThreadsAccountData{}}
- }
- list := make([]types.ThreadsAccountData, 0, len(result.List))
- for _, item := range result.List {
- list = append(list, toThreadsAccountData(item))
- }
- return &types.ListThreadsAccountsData{
- List: list,
- ActiveAccountID: result.ActiveAccountID,
- }
-}
-
-func toConnectionData(data *domusecase.ConnectionData) *types.ThreadsAccountConnectionData {
- if data == nil {
- return nil
- }
- return &types.ThreadsAccountConnectionData{
- AccountID: data.AccountID,
- AccountName: data.AccountName,
- Username: data.Username,
- BrowserConnected: data.BrowserConnected,
- ApiConnected: data.ApiConnected,
- Prefs: types.ThreadsAccountConnectionPrefs{
- SearchViaApi: data.Prefs.SearchViaApi,
- SearchSourceMode: data.Prefs.SearchSourceMode,
- PublishViaApi: data.Prefs.PublishViaApi,
- DevMode: data.Prefs.DevMode,
- ScrapeReplies: data.Prefs.ScrapeReplies,
- RepliesPerPost: data.Prefs.RepliesPerPost,
- PublishHeaded: data.Prefs.PublishHeaded,
- PlaywrightDebug: data.Prefs.PlaywrightDebug,
- },
- }
-}
-
-func toPublishQueueItemData(item *pqdomain.QueueItemSummary) *types.PublishQueueItemData {
- if item == nil {
- return nil
- }
- return &types.PublishQueueItemData{
- ID: item.ID,
- AccountID: item.AccountID,
- PersonaID: item.PersonaID,
- CopyMissionID: item.CopyMissionID,
- CopyDraftID: item.CopyDraftID,
- Text: item.Text,
- ImageKey: item.ImageKey,
- ImageKeys: item.ImageKeys,
- TopicTag: item.TopicTag,
- ScheduledAt: item.ScheduledAt,
- Status: item.Status,
- MediaID: item.MediaID,
- Permalink: item.Permalink,
- PublishedAt: item.PublishedAt,
- ErrorMessage: item.ErrorMessage,
- RetryCount: item.RetryCount,
- LastAttemptAt: item.LastAttemptAt,
- NextAttemptAt: item.NextAttemptAt,
- MissedAt: item.MissedAt,
- PausedReason: item.PausedReason,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toInventoryPolicyData(item *inventorydomain.PolicySummary) *types.PublishInventoryPolicyData {
- if item == nil {
- return nil
- }
- slots := make([]types.PublishSlotData, 0, len(item.Slots))
- for _, slot := range item.Slots {
- slots = append(slots, types.PublishSlotData{Weekday: slot.Weekday, Time: slot.Time})
- }
- refs := make([]types.PublishSourceRefData, 0, len(item.SourceRefs))
- for _, ref := range item.SourceRefs {
- refs = append(refs, types.PublishSourceRefData{
- Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID, ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed,
- })
- }
- return &types.PublishInventoryPolicyData{
- AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount,
- LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays,
- Timezone: item.Timezone, Slots: slots, SourceRefs: refs, UpdateAt: item.UpdateAt,
- }
-}
-
-func toGuardPolicyData(item *guarddomain.PolicySummary) *types.PublishGuardPolicyData {
- if item == nil {
- return nil
- }
- return &types.PublishGuardPolicyData{
- AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts,
- MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit,
- Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt,
- }
-}
-
-func toQueueEventData(items []eventdomain.EventSummary) []types.PublishQueueEventData {
- out := make([]types.PublishQueueEventData, 0, len(items))
- for _, item := range items {
- out = append(out, types.PublishQueueEventData{
- ID: item.ID, QueueID: item.QueueID, EventType: item.EventType,
- FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt,
- })
- }
- return out
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if value != "" {
- return value
- }
- }
- return ""
-}
-
-func toPublishQueueListData(result *pqdomain.ListResult) *types.PublishQueueListData {
- if result == nil {
- return &types.PublishQueueListData{List: []types.PublishQueueItemData{}}
- }
- list := make([]types.PublishQueueItemData, 0, len(result.Items))
- for _, item := range result.Items {
- if data := toPublishQueueItemData(&item); data != nil {
- list = append(list, *data)
- }
- }
- return &types.PublishQueueListData{
- Pagination: types.PaginationData{
- Total: result.Total,
- Page: int64(result.Page),
- PageSize: int64(result.PageSize),
- TotalPages: int64(result.TotalPages),
- },
- List: list,
- }
-}
-
-func toPublishHealthData(result *pqdomain.PublishHealthResult) *types.ThreadsPublishHealthData {
- if result == nil {
- return &types.ThreadsPublishHealthData{List: []types.ThreadsPublishHealthItem{}}
- }
- list := make([]types.ThreadsPublishHealthItem, 0, len(result.Items))
- for _, item := range result.Items {
- cps := make([]types.ThreadsPublishHealthCheckpoint, 0, len(item.Checkpoints))
- for _, cp := range item.Checkpoints {
- cps = append(cps, types.ThreadsPublishHealthCheckpoint{
- Checkpoint: cp.Checkpoint, LikeCount: cp.LikeCount, ReplyCount: cp.ReplyCount,
- RepostCount: cp.RepostCount, QuoteCount: cp.QuoteCount, Views: cp.Views, CheckedAt: cp.CheckedAt,
- })
- }
- list = append(list, types.ThreadsPublishHealthItem{
- MediaID: item.MediaID, Permalink: item.Permalink, Text: item.Text,
- PublishedAt: item.PublishedAt, LikeCount: item.LikeCount, ReplyCount: item.ReplyCount,
- RepostCount: item.RepostCount, QuoteCount: item.QuoteCount,
- Views: item.Views, Shares: item.Shares, Checkpoints: cps,
- })
- }
- return &types.ThreadsPublishHealthData{
- Summary: types.ThreadsPublishHealthSummary{
- PendingScheduled: result.Summary.PendingScheduled,
- FailedCount: result.Summary.FailedCount,
- Published7d: result.Summary.Published7d,
- TotalLikes7d: result.Summary.TotalLikes7d,
- TotalReplies7d: result.Summary.TotalReplies7d,
- },
- Pagination: types.PaginationData{
- Total: result.Total, Page: int64(result.Page), PageSize: int64(result.PageSize), TotalPages: int64(result.TotalPages),
- },
- List: list,
- }
-}
-
-func toConnectionPatch(req *types.UpdateThreadsAccountConnectionReq) domusecase.ConnectionPrefsPatch {
- if req == nil {
- return domusecase.ConnectionPrefsPatch{}
- }
- return domusecase.ConnectionPrefsPatch{
- SearchViaApi: req.SearchViaApi,
- SearchSourceMode: req.SearchSourceMode,
- PublishViaApi: req.PublishViaApi,
- DevMode: req.DevMode,
- ScrapeReplies: req.ScrapeReplies,
- RepliesPerPost: req.RepliesPerPost,
- PublishHeaded: req.PublishHeaded,
- PlaywrightDebug: req.PlaywrightDebug,
- }
-}
diff --git a/old/backend/internal/logic/threads_account/mention_inbox_mapper.go b/old/backend/internal/logic/threads_account/mention_inbox_mapper.go
deleted file mode 100644
index 867d844..0000000
--- a/old/backend/internal/logic/threads_account/mention_inbox_mapper.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package threads_account
-
-import (
- mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
- "haixun-backend/internal/types"
-)
-
-func toMentionInboxItems(items []mentioninboxdomain.MentionSummary) []types.MentionInboxItemData {
- out := make([]types.MentionInboxItemData, 0, len(items))
- for _, item := range items {
- out = append(out, types.MentionInboxItemData{
- MediaID: item.MediaID,
- AuthorUsername: item.AuthorUsername,
- Text: item.Text,
- Permalink: item.Permalink,
- Shortcode: item.Shortcode,
- Timestamp: item.Timestamp,
- MediaType: item.MediaType,
- IsReply: item.IsReply,
- IsQuotePost: item.IsQuotePost,
- HasReplies: item.HasReplies,
- RootPostID: item.RootPostID,
- ParentID: item.ParentID,
- ThreadPostText: item.ThreadPostText,
- ParentReplyText: item.ParentReplyText,
- ReplyReady: item.ReplyReady,
- ReplyReadyMsg: item.ReplyReadyMsg,
- SyncedAt: item.SyncedAt,
- })
- }
- return out
-}
diff --git a/old/backend/internal/logic/threads_account/own_post_formula_mapper.go b/old/backend/internal/logic/threads_account/own_post_formula_mapper.go
deleted file mode 100644
index a6336c0..0000000
--- a/old/backend/internal/logic/threads_account/own_post_formula_mapper.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package threads_account
-
-import (
- "haixun-backend/internal/model/own_post_formula/domain/entity"
- "haixun-backend/internal/types"
-)
-
-func toOwnPostFormulaReviewData(item *entity.Review) types.OwnPostFormulaReviewData {
- if item == nil {
- return types.OwnPostFormulaReviewData{}
- }
- return types.OwnPostFormulaReviewData{
- MediaID: item.MediaID,
- ReviewedAt: item.UpdateAt,
- Summary: item.Summary,
- Wins: item.Wins,
- Improvements: item.Improvements,
- Formula: item.Formula,
- PostTemplate: item.PostTemplate,
- HookPattern: item.HookPattern,
- Structure: item.Structure,
- ReplicationTips: item.ReplicationTips,
- Avoid: item.Avoid,
- }
-}
diff --git a/old/backend/internal/logic/threads_account/patch_content_formula_logic.go b/old/backend/internal/logic/threads_account/patch_content_formula_logic.go
deleted file mode 100644
index 7e994aa..0000000
--- a/old/backend/internal/logic/threads_account/patch_content_formula_logic.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- cfdom "haixun-backend/internal/model/content_formula/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type PatchContentFormulaLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPatchContentFormulaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchContentFormulaLogic {
- return &PatchContentFormulaLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PatchContentFormulaLogic) PatchContentFormula(req *types.PatchContentFormulaHandlerReq) (*types.ContentFormulaData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ContentFormula.Update(l.ctx, cfdom.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- FormulaID: req.FormulaID,
- Label: req.Label,
- Formula: req.Formula,
- PostTemplate: req.PostTemplate,
- HookPattern: req.HookPattern,
- Structure: req.Structure,
- Tags: req.Tags,
- })
- if err != nil {
- return nil, err
- }
- data := toContentFormulaData(*item)
- return &data, nil
-}
diff --git a/old/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go b/old/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go
deleted file mode 100644
index 6bbd710..0000000
--- a/old/backend/internal/logic/threads_account/patch_publish_queue_item_logic.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- pqdomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type PatchPublishQueueItemLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPatchPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchPublishQueueItemLogic {
- return &PatchPublishQueueItemLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *PatchPublishQueueItemLogic) PatchPublishQueueItem(req *types.PatchPublishQueueHandlerReq) (*types.PublishQueueItemData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.PublishQueue.Update(l.ctx, pqdomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- QueueID: req.QID,
- Text: req.Text,
- ImageKey: req.ImageKey,
- ImageKeys: req.ImageKeys,
- TopicTag: req.TopicTag,
- ScheduledAt: req.ScheduledAt,
- })
- if err != nil {
- return nil, err
- }
- return toPublishQueueItemData(result), nil
-}
diff --git a/old/backend/internal/logic/threads_account/publish_attachment.go b/old/backend/internal/logic/threads_account/publish_attachment.go
deleted file mode 100644
index c681406..0000000
--- a/old/backend/internal/logic/threads_account/publish_attachment.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package threads_account
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/storage"
- "haixun-backend/internal/svc"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-func resolvePublishImageURLs(svcCtx *svc.ServiceContext, single string, list []string) ([]string, []string, error) {
- keys := storage.NormalizeImageKeys(single, list)
- if len(keys) == 0 {
- return nil, nil, nil
- }
- resolver, ok := svcCtx.AvatarStorage.(storage.PublicURLResolver)
- if !ok || resolver == nil {
- return nil, nil, app.For(code.ThreadsAccount).SysNotImplemented("image storage is not configured")
- }
- urls := make([]string, 0, len(keys))
- for _, key := range keys {
- if err := storage.ValidatePublishAttachmentKey(key); err != nil {
- return nil, nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- url, err := resolver.ResolvePublicURL(key)
- if err != nil {
- return nil, nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- if err := storage.ValidateThreadsFetchableURL(url); err != nil {
- return nil, nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- urls = append(urls, url)
- }
- return urls, keys, nil
-}
-
-func purgePublishAttachments(ctx context.Context, svcCtx *svc.ServiceContext, imageKeys []string) {
- deleter, ok := svcCtx.AvatarStorage.(storage.ObjectDeleter)
- if !ok || deleter == nil {
- return
- }
- for _, key := range imageKeys {
- key = strings.TrimSpace(key)
- if key == "" {
- continue
- }
- if err := storage.PurgePublishAttachment(ctx, deleter, key); err != nil {
- logx.WithContext(ctx).Errorf("purge publish attachment %s: %v", key, err)
- }
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/threads_account/publish_mention_reply_logic.go b/old/backend/internal/logic/threads_account/publish_mention_reply_logic.go
deleted file mode 100644
index 2be218e..0000000
--- a/old/backend/internal/logic/threads_account/publish_mention_reply_logic.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PublishMentionReplyLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPublishMentionReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishMentionReplyLogic {
- return &PublishMentionReplyLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *PublishMentionReplyLogic) PublishMentionReply(
- req *types.PublishMentionReplyHandlerReq,
- image *ReplyImageFile,
-) (*types.PublishMentionReplyData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- imageURLs, cleanup, err := replyImageURLs(l.svcCtx, image)
- if err != nil {
- return nil, err
- }
- defer cleanup()
-
- result, err := l.svcCtx.MentionInbox.PublishReply(l.ctx, mentioninboxdomain.PublishReplyRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- MediaID: req.MediaID,
- Text: req.Text,
- ImageURLs: imageURLs,
- })
- if err != nil {
- return nil, err
- }
- return &types.PublishMentionReplyData{
- MediaID: result.MediaID,
- Permalink: result.Permalink,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/publish_threads_reply_logic.go b/old/backend/internal/logic/threads_account/publish_threads_reply_logic.go
deleted file mode 100644
index e22b913..0000000
--- a/old/backend/internal/logic/threads_account/publish_threads_reply_logic.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package threads_account
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/threadspost"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type PublishThreadsReplyLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewPublishThreadsReplyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishThreadsReplyLogic {
- return &PublishThreadsReplyLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *PublishThreadsReplyLogic) PublishThreadsReply(
- req *types.PublishThreadsReplyHandlerReq,
- image *ReplyImageFile,
-) (*types.PublishThreadsReplyData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- replyToID := strings.TrimSpace(req.ReplyToID)
- text := strings.TrimSpace(req.Text)
- if replyToID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("reply_to_id is required")
- }
- imageURLs, cleanup, err := replyImageURLs(l.svcCtx, image)
- if err != nil {
- return nil, err
- }
- defer cleanup()
- if text == "" && len(imageURLs) == 0 {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("text or image is required")
- }
- if err := threadspost.ValidateReplyContent(text, len(imageURLs) > 0); err != nil {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
-
- account, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- token, err := l.svcCtx.ThreadsAccount.ResolveAccountAPIAccessToken(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- userID := strings.TrimSpace(account.ThreadsUserID)
- if userID == "" {
- if profile, perr := libthreads.GetMeProfile(l.ctx, token); perr == nil && profile != nil {
- userID = profile.ID.String()
- }
- }
-
- result, err := libthreads.PublishReply(l.ctx, libthreads.PublishReplyInput{
- ThreadsUserID: userID,
- AccessToken: token,
- ReplyToID: replyToID,
- Text: text,
- ImageURLs: imageURLs,
- })
- if err != nil {
- return nil, err
- }
- out := &types.PublishThreadsReplyData{}
- if result != nil {
- out.MediaID = strings.TrimSpace(result.MediaID)
- out.Permalink = strings.TrimSpace(result.Permalink)
- }
- return out, nil
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/threads_account/reply_image.go b/old/backend/internal/logic/threads_account/reply_image.go
deleted file mode 100644
index 6a7509e..0000000
--- a/old/backend/internal/logic/threads_account/reply_image.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package threads_account
-
-import (
- "io"
- "path/filepath"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/storage"
- "haixun-backend/internal/svc"
-)
-
-const maxReplyImageBytes = 8 << 20
-
-type ReplyImageFile struct {
- Filename string
- ContentType string
- Body io.Reader
-}
-
-func stageReplyImageURL(svcCtx *svc.ServiceContext, file *ReplyImageFile) (imageURL string, cleanup func(), err error) {
- cleanup = func() {}
- if file == nil || file.Body == nil {
- return "", cleanup, nil
- }
- ext := strings.ToLower(filepath.Ext(strings.TrimSpace(file.Filename)))
- if !allowedPublishAttachmentExt(ext) {
- return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat("image must be jpg, png, webp, or gif")
- }
- contentType := strings.TrimSpace(file.ContentType)
- if contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "image/") {
- return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat("file must be an image")
- }
- if contentType == "" {
- switch ext {
- case ".jpg", ".jpeg":
- contentType = "image/jpeg"
- case ".png":
- contentType = "image/png"
- case ".webp":
- contentType = "image/webp"
- case ".gif":
- contentType = "image/gif"
- default:
- contentType = "application/octet-stream"
- }
- }
-
- limited := io.LimitReader(file.Body, maxReplyImageBytes+1)
- data, readErr := io.ReadAll(limited)
- if readErr != nil {
- return "", cleanup, app.For(code.ThreadsAccount).SysInternal("read image failed").WithCause(readErr)
- }
- if len(data) == 0 {
- return "", cleanup, app.For(code.ThreadsAccount).InputMissingRequired("image file is required")
- }
- if len(data) > maxReplyImageBytes {
- return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat("image must be smaller than 8MB")
- }
-
- key, regErr := storage.DefaultEphemeralAssetStore().Register(data, contentType, 0)
- if regErr != nil {
- return "", cleanup, app.For(code.ThreadsAccount).SysInternal("stage image failed").WithCause(regErr)
- }
- base := strings.TrimSpace(svcCtx.Config.Storage.S3.PublicBaseURL)
- imageURL, urlErr := storage.BuildEphemeralPublicURL(base, key)
- if urlErr != nil {
- storage.DefaultEphemeralAssetStore().Delete(key)
- return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat(urlErr.Error())
- }
- if err := storage.ValidateThreadsFetchableURL(imageURL); err != nil {
- storage.DefaultEphemeralAssetStore().Delete(key)
- return "", cleanup, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- cleanup = func() {
- storage.DefaultEphemeralAssetStore().Delete(key)
- }
- return imageURL, cleanup, nil
-}
-
-func replyImageURLs(svcCtx *svc.ServiceContext, file *ReplyImageFile) ([]string, func(), error) {
- imageURL, cleanup, err := stageReplyImageURL(svcCtx, file)
- if err != nil || imageURL == "" {
- return nil, cleanup, err
- }
- return []string{imageURL}, cleanup, nil
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/threads_account/resume_publish_guard_logic.go b/old/backend/internal/logic/threads_account/resume_publish_guard_logic.go
deleted file mode 100644
index 8a52f9d..0000000
--- a/old/backend/internal/logic/threads_account/resume_publish_guard_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type ResumePublishGuardLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewResumePublishGuardLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResumePublishGuardLogic {
- return &ResumePublishGuardLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *ResumePublishGuardLogic) ResumePublishGuard(req *types.ThreadsAccountPath) (resp *types.PublishGuardPolicyData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PublishGuard.Resume(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- return toGuardPolicyData(item), nil
-}
diff --git a/old/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go b/old/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go
deleted file mode 100644
index bd0b8e1..0000000
--- a/old/backend/internal/logic/threads_account/retry_publish_queue_item_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type RetryPublishQueueItemLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRetryPublishQueueItemLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryPublishQueueItemLogic {
- return &RetryPublishQueueItemLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *RetryPublishQueueItemLogic) RetryPublishQueueItem(req *types.PublishQueueItemPath) (resp *types.PublishQueueItemData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PublishQueue.Retry(l.ctx, tenantID, uid, req.ID, req.QID)
- if err != nil {
- return nil, err
- }
- return toPublishQueueItemData(item), nil
-}
diff --git a/old/backend/internal/logic/threads_account/run_threads_api_playground_logic.go b/old/backend/internal/logic/threads_account/run_threads_api_playground_logic.go
deleted file mode 100644
index 9042868..0000000
--- a/old/backend/internal/logic/threads_account/run_threads_api_playground_logic.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package threads_account
-
-import (
- "context"
- "strings"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type RunThreadsAPIPlaygroundLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRunThreadsAPIPlaygroundLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunThreadsAPIPlaygroundLogic {
- return &RunThreadsAPIPlaygroundLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *RunThreadsAPIPlaygroundLogic) RunThreadsAPIPlayground(
- req *types.RunThreadsAPIPlaygroundHandlerReq,
-) (*types.ThreadsAPIPlaygroundData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- body := req.RunThreadsAPIPlaygroundReq
- imageURLs, imageKeys, err := resolvePublishImageURLs(l.svcCtx, body.ImageKey, body.ImageKeys)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.ThreadsAccount.RunAPIPlayground(l.ctx, tenantID, uid, req.ID, domusecase.APIPlaygroundRequest{
- Action: body.Action, Query: body.Query, Username: body.Username,
- MediaID: body.MediaID, ReplyID: body.ReplyID, ReplyToID: body.ReplyToID,
- Text: body.Text, ImageURLs: imageURLs, Permalink: body.Permalink, HintText: body.HintText, SkipPrime: body.SkipPrime,
- LocationQuery: body.LocationQuery, Limit: body.Limit,
- Hide: body.Hide, SearchType: body.SearchType,
- })
- if err != nil {
- return nil, err
- }
- if result.Ok && len(imageKeys) > 0 {
- switch strings.TrimSpace(strings.ToLower(body.Action)) {
- case "publish_text":
- purgePublishAttachments(l.ctx, l.svcCtx, imageKeys)
- }
- }
- data := ""
- if len(result.Data) > 0 {
- data = string(result.Data)
- }
- return &types.ThreadsAPIPlaygroundData{
- Action: result.Action, Ok: result.Ok, Message: result.Message, Data: data,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go b/old/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go
deleted file mode 100644
index f91d22e..0000000
--- a/old/backend/internal/logic/threads_account/run_threads_api_smoke_test_logic.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type RunThreadsAPISmokeTestLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewRunThreadsAPISmokeTestLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RunThreadsAPISmokeTestLogic {
- return &RunThreadsAPISmokeTestLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *RunThreadsAPISmokeTestLogic) RunThreadsAPISmokeTest(req *types.ThreadsAccountPath) (*types.RunThreadsAPISmokeTestData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- items, err := l.svcCtx.ThreadsAccount.RunAPISmokeTest(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- list := make([]types.ThreadsAPISmokeTestItem, 0, len(items))
- var passed, failed, skipped int
- for _, item := range items {
- list = append(list, types.ThreadsAPISmokeTestItem{
- Scope: item.Scope, Name: item.Name, Status: item.Status,
- Message: item.Message, Count: item.Count,
- })
- switch item.Status {
- case "ok":
- passed++
- case "error":
- failed++
- default:
- skipped++
- }
- }
- return &types.RunThreadsAPISmokeTestData{
- List: list,
- Passed: passed,
- Failed: failed,
- Skipped: skipped,
- Total: len(list),
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/search_content_formula_posts_logic.go b/old/backend/internal/logic/threads_account/search_content_formula_posts_logic.go
deleted file mode 100644
index 7b8c99b..0000000
--- a/old/backend/internal/logic/threads_account/search_content_formula_posts_logic.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package threads_account
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- libviral "haixun-backend/internal/library/viral"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type SearchContentFormulaPostsLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewSearchContentFormulaPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SearchContentFormulaPostsLogic {
- return &SearchContentFormulaPostsLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *SearchContentFormulaPostsLogic) SearchContentFormulaPosts(req *types.SearchContentFormulaPostsHandlerReq) (*types.SearchContentFormulaPostsData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- keyword := strings.TrimSpace(req.Keyword)
- if keyword == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("keyword is required")
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
- return nil, err
- }
- token, err := l.svcCtx.ThreadsAccount.ResolveAccountAPIAccessToken(l.ctx, tenantID, uid, req.ID)
- if err != nil {
- return nil, err
- }
- client := libthreads.NewClient(token)
- if !client.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
- }
- limit := req.Limit
- if limit <= 0 {
- limit = 10
- }
- searchType := strings.TrimSpace(req.SearchType)
- if searchType == "" {
- searchType = "TOP"
- }
- items, err := client.KeywordSearch(l.ctx, libthreads.KeywordSearchOptions{
- Query: keyword, Limit: limit, SearchType: searchType,
- })
- if err != nil {
- return nil, app.For(code.ThreadsAccount).SvcThirdParty("keyword_search 失敗:" + err.Error())
- }
- list := make([]types.ContentFormulaSearchPostData, 0, len(items))
- for _, item := range items {
- text := strings.TrimSpace(item.Text)
- if text == "" {
- continue
- }
- author := strings.TrimSpace(item.Username)
- list = append(list, types.ContentFormulaSearchPostData{
- Text: text,
- Author: author,
- Permalink: item.Permalink,
- MediaID: item.ID,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- EngagementScore: libviral.ScorePost(item.LikeCount, item.ReplyCount),
- })
- }
- return &types.SearchContentFormulaPostsData{
- List: list,
- Message: fmt.Sprintf("找到 %d 篇相似貼文", len(list)),
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/start_threads_oauth_logic.go b/old/backend/internal/logic/threads_account/start_threads_oauth_logic.go
deleted file mode 100644
index 94a5b73..0000000
--- a/old/backend/internal/logic/threads_account/start_threads_oauth_logic.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type StartThreadsOAuthLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewStartThreadsOAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartThreadsOAuthLogic {
- return &StartThreadsOAuthLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *StartThreadsOAuthLogic) StartThreadsOAuth(req *types.StartThreadsOAuthQuery) (*types.StartThreadsOAuthData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- accountID := ""
- if req != nil {
- accountID = req.AccountID
- }
- result, err := l.svcCtx.ThreadsAccount.StartThreadsOAuth(l.ctx, tenantID, uid, accountID)
- if err != nil {
- return nil, err
- }
- return &types.StartThreadsOAuthData{
- AuthorizeURL: result.AuthorizeURL,
- State: result.State,
- AccountID: result.AccountID,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/sync_mention_inbox_logic.go b/old/backend/internal/logic/threads_account/sync_mention_inbox_logic.go
deleted file mode 100644
index 3ed8f70..0000000
--- a/old/backend/internal/logic/threads_account/sync_mention_inbox_logic.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type SyncMentionInboxLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewSyncMentionInboxLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SyncMentionInboxLogic {
- return &SyncMentionInboxLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *SyncMentionInboxLogic) SyncMentionInbox(req *types.SyncMentionInboxHandlerReq) (*types.SyncMentionInboxData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- result, err := l.svcCtx.MentionInbox.SyncFromAPI(l.ctx, mentioninboxdomain.SyncRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- Limit: req.Limit,
- MaxPages: req.MaxPages,
- })
- if err != nil {
- return nil, err
- }
- return &types.SyncMentionInboxData{
- Synced: result.Synced,
- Ready: result.Ready,
- Total: result.Total,
- }, nil
-}
diff --git a/old/backend/internal/logic/threads_account/threads_oauth_callback_logic.go b/old/backend/internal/logic/threads_account/threads_oauth_callback_logic.go
deleted file mode 100644
index 2215fdf..0000000
--- a/old/backend/internal/logic/threads_account/threads_oauth_callback_logic.go
+++ /dev/null
@@ -1,60 +0,0 @@
-package threads_account
-
-import (
- "context"
- "net/url"
- "strings"
-
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type ThreadsOAuthCallbackLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewThreadsOAuthCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ThreadsOAuthCallbackLogic {
- return &ThreadsOAuthCallbackLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *ThreadsOAuthCallbackLogic) ThreadsOAuthCallback(req *types.ThreadsOAuthCallbackQuery) (string, error) {
- code := ""
- state := ""
- if req != nil {
- code = req.Code
- state = req.State
- if strings.TrimSpace(req.Error) != "" {
- msg := strings.TrimSpace(req.Error)
- if reason := strings.TrimSpace(req.ErrorReason); reason != "" {
- msg += " (" + reason + ")"
- }
- if desc := strings.TrimSpace(req.ErrorDescription); desc != "" {
- msg += ": " + desc
- }
- l.svcCtx.ThreadsAccount.RecordOAuthDebug("error", "meta_denied", "", msg)
- return failureRedirect(l.svcCtx.Config.ThreadsOAuth.SuccessRedirect, msg), nil
- }
- }
- result, err := l.svcCtx.ThreadsAccount.CompleteThreadsOAuthCallback(l.ctx, code, state)
- if err != nil {
- return failureRedirect(l.svcCtx.Config.ThreadsOAuth.SuccessRedirect, err.Error()), nil
- }
- return result.RedirectURL, nil
-}
-
-func failureRedirect(base, message string) string {
- base = strings.TrimSpace(base)
- if base == "" {
- base = "/"
- }
- sep := "?"
- if strings.Contains(base, "?") {
- sep = "&"
- }
- return base + sep + "threads_oauth=error&message=" + url.QueryEscape(message)
-}
-
-func OAuthFailureRedirect(successRedirect, message string) string {
- return failureRedirect(successRedirect, message)
-}
diff --git a/old/backend/internal/logic/threads_account/update_threads_account_ai_settings_logic.go b/old/backend/internal/logic/threads_account/update_threads_account_ai_settings_logic.go
deleted file mode 100644
index 913c1cf..0000000
--- a/old/backend/internal/logic/threads_account/update_threads_account_ai_settings_logic.go
+++ /dev/null
@@ -1,53 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateThreadsAccountAiSettingsLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateThreadsAccountAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateThreadsAccountAiSettingsLogic {
- return &UpdateThreadsAccountAiSettingsLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateThreadsAccountAiSettingsLogic) UpdateThreadsAccountAiSettings(req *types.UpdateThreadsAccountAiSettingsHandlerReq) (resp *types.ThreadsAccountAiSettingsData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- data, err := l.svcCtx.ThreadsAccount.UpdateAiSettings(l.ctx, tenantID, uid, req.ID, toAiSettingsPatch(&req.UpdateThreadsAccountAiSettingsReq))
- if err != nil {
- return nil, err
- }
- return toAiSettingsData(data), nil
-}
-
-func toAiSettingsPatch(req *types.UpdateThreadsAccountAiSettingsReq) domusecase.AiSettingsPatch {
- if req == nil {
- return domusecase.AiSettingsPatch{}
- }
- return domusecase.AiSettingsPatch{
- Provider: req.Provider,
- Model: req.Model,
- ResearchProvider: req.ResearchProvider,
- ResearchModel: req.ResearchModel,
- ApiKeys: req.ApiKeys,
- }
-}
diff --git a/old/backend/internal/logic/threads_account/update_threads_account_connection_logic.go b/old/backend/internal/logic/threads_account/update_threads_account_connection_logic.go
deleted file mode 100644
index 701eac7..0000000
--- a/old/backend/internal/logic/threads_account/update_threads_account_connection_logic.go
+++ /dev/null
@@ -1,45 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpdateThreadsAccountConnectionLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateThreadsAccountConnectionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateThreadsAccountConnectionLogic {
- return &UpdateThreadsAccountConnectionLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpdateThreadsAccountConnectionLogic) UpdateThreadsAccountConnection(req *types.UpdateThreadsAccountConnectionHandlerReq) (resp *types.ThreadsAccountConnectionData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- data, err := l.svcCtx.ThreadsAccount.UpdateConnection(l.ctx, domusecase.UpdateConnectionRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- Prefs: toConnectionPatch(&req.UpdateThreadsAccountConnectionReq),
- })
- if err != nil {
- return nil, err
- }
- return toConnectionData(data), nil
-}
diff --git a/old/backend/internal/logic/threads_account/update_threads_account_logic.go b/old/backend/internal/logic/threads_account/update_threads_account_logic.go
deleted file mode 100644
index 4620b72..0000000
--- a/old/backend/internal/logic/threads_account/update_threads_account_logic.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package threads_account
-
-import (
- "context"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-)
-
-type UpdateThreadsAccountLogic struct {
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpdateThreadsAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateThreadsAccountLogic {
- return &UpdateThreadsAccountLogic{ctx: ctx, svcCtx: svcCtx}
-}
-
-func (l *UpdateThreadsAccountLogic) UpdateThreadsAccount(
- req *types.UpdateThreadsAccountHandlerReq,
-) (*types.ThreadsAccountData, error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.ThreadsAccount.Update(l.ctx, domusecase.UpdateAccountRequest{
- TenantID: tenantID,
- OwnerUID: uid,
- AccountID: req.ID,
- DisplayName: req.DisplayName,
- PersonaID: req.PersonaID,
- })
- if err != nil {
- return nil, err
- }
- out := toThreadsAccountData(*item)
- return &out, nil
-}
diff --git a/old/backend/internal/logic/threads_account/upload_publish_attachment_logic.go b/old/backend/internal/logic/threads_account/upload_publish_attachment_logic.go
deleted file mode 100644
index 6e41985..0000000
--- a/old/backend/internal/logic/threads_account/upload_publish_attachment_logic.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package threads_account
-
-import (
- "context"
- "fmt"
- "io"
- "path/filepath"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/storage"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/google/uuid"
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UploadPublishAttachmentLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUploadPublishAttachmentLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadPublishAttachmentLogic {
- return &UploadPublishAttachmentLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UploadPublishAttachmentLogic) UploadPublishAttachment(accountID, filename, contentType string, body io.Reader) (*types.UploadPublishAttachmentData, error) {
- if l.svcCtx.AvatarStorage == nil {
- return nil, app.For(code.ThreadsAccount).SysNotImplemented("image storage is not configured")
- }
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- accountID = strings.TrimSpace(accountID)
- if accountID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, accountID); err != nil {
- return nil, err
- }
- ext := strings.ToLower(filepath.Ext(filename))
- if !allowedPublishAttachmentExt(ext) {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat("image must be jpg, png, webp, or gif")
- }
- if contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "image/") {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat("file must be an image")
- }
- key := fmt.Sprintf("%s%s/%s/%s%s", storage.PublishAttachmentKeyPrefix, tenantID, accountID, uuid.NewString(), ext)
- storedKey, err := l.svcCtx.AvatarStorage.Upload(l.ctx, key, contentType, body)
- if err != nil {
- return nil, app.For(code.ThreadsAccount).SysInternal("upload image failed").WithCause(err)
- }
- urls, _, err := resolvePublishImageURLs(l.svcCtx, storedKey, nil)
- if err != nil {
- return nil, err
- }
- publicURL := ""
- if len(urls) > 0 {
- publicURL = urls[0]
- }
- return &types.UploadPublishAttachmentData{
- AttachmentKey: storedKey,
- PublicURL: publicURL,
- }, nil
-}
-
-func allowedPublishAttachmentExt(ext string) bool {
- switch ext {
- case ".jpg", ".jpeg", ".png", ".webp", ".gif":
- return true
- default:
- return false
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go b/old/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go
deleted file mode 100644
index 8eb83ad..0000000
--- a/old/backend/internal/logic/threads_account/upsert_publish_guard_policy_logic.go
+++ /dev/null
@@ -1,45 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpsertPublishGuardPolicyLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertPublishGuardPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishGuardPolicyLogic {
- return &UpsertPublishGuardPolicyLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpsertPublishGuardPolicyLogic) UpsertPublishGuardPolicy(req *types.UpsertPublishGuardPolicyHandlerReq) (resp *types.PublishGuardPolicyData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- item, err := l.svcCtx.PublishGuard.Upsert(l.ctx, guarddomain.UpsertRequest{
- TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled,
- MaxDailyPosts: req.MaxDailyPosts, MinIntervalMinutes: req.MinIntervalMinutes,
- ConsecutiveFailurePauseLimit: req.ConsecutiveFailurePauseLimit,
- Paused: req.Paused, PausedReason: req.PausedReason,
- })
- if err != nil {
- return nil, err
- }
- return toGuardPolicyData(item), nil
-}
diff --git a/old/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go b/old/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go
deleted file mode 100644
index a46c585..0000000
--- a/old/backend/internal/logic/threads_account/upsert_publish_inventory_policy_logic.go
+++ /dev/null
@@ -1,55 +0,0 @@
-// Code scaffolded by goctl. Safe to edit.
-// goctl 1.10.1
-
-package threads_account
-
-import (
- "context"
-
- inventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
- "haixun-backend/internal/svc"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/core/logx"
-)
-
-type UpsertPublishInventoryPolicyLogic struct {
- logx.Logger
- ctx context.Context
- svcCtx *svc.ServiceContext
-}
-
-func NewUpsertPublishInventoryPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertPublishInventoryPolicyLogic {
- return &UpsertPublishInventoryPolicyLogic{
- Logger: logx.WithContext(ctx),
- ctx: ctx,
- svcCtx: svcCtx,
- }
-}
-
-func (l *UpsertPublishInventoryPolicyLogic) UpsertPublishInventoryPolicy(req *types.UpsertPublishInventoryPolicyHandlerReq) (resp *types.PublishInventoryPolicyData, err error) {
- tenantID, uid, err := actorFrom(l.ctx)
- if err != nil {
- return nil, err
- }
- slots := make([]inventorydomain.Slot, 0, len(req.Slots))
- for _, slot := range req.Slots {
- slots = append(slots, inventorydomain.Slot{Weekday: slot.Weekday, Time: slot.Time})
- }
- refs := make([]inventorydomain.SourceRef, 0, len(req.SourceRefs))
- for _, ref := range req.SourceRefs {
- refs = append(refs, inventorydomain.SourceRef{
- Type: ref.Type, PersonaID: ref.PersonaID, CopyMissionID: ref.CopyMissionID,
- ScanPostID: ref.ScanPostID, ManualSeed: ref.ManualSeed,
- })
- }
- item, err := l.svcCtx.PublishInventory.Upsert(l.ctx, inventorydomain.UpsertRequest{
- TenantID: tenantID, OwnerUID: uid, AccountID: req.ID, Enabled: req.Enabled,
- TargetDailyCount: req.TargetDailyCount, LowStockThreshold: req.LowStockThreshold,
- LookaheadDays: req.LookaheadDays, Timezone: req.Timezone, Slots: slots, SourceRefs: refs,
- })
- if err != nil {
- return nil, err
- }
- return toInventoryPolicyData(item), nil
-}
diff --git a/old/backend/internal/middleware/auth.go b/old/backend/internal/middleware/auth.go
deleted file mode 100644
index be8f782..0000000
--- a/old/backend/internal/middleware/auth.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package middleware
-
-import (
- "net/http"
- "strings"
-
- "haixun-backend/internal/config"
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- authusecase "haixun-backend/internal/model/auth/domain/usecase"
- "haixun-backend/internal/response"
-
- "github.com/zeromicro/go-zero/rest"
-)
-
-const MemberAuthorizationHeader = "X-Member-Authorization"
-
-// Auth validates member JWT from Authorization: Bearer .
-// Use for all protected routes except AI endpoints that reserve Authorization for provider tokens.
-func Auth(tokens authusecase.TokenUseCase, cfg config.AuthConf, next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- actor, err := resolveActor(r, tokens, cfg, r.Header.Get("Authorization"), "missing bearer token")
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
- next(w, r.WithContext(authctx.WithActor(r.Context(), actor)))
- }
-}
-
-// MemberAuth validates member JWT from X-Member-Authorization.
-// AI routes keep Authorization for provider API keys.
-func MemberAuth(tokens authusecase.TokenUseCase, cfg config.AuthConf, next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- actor, err := resolveActor(r, tokens, cfg, r.Header.Get(MemberAuthorizationHeader), "missing member bearer token")
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
- next(w, r.WithContext(authctx.WithActor(r.Context(), actor)))
- }
-}
-
-// RestAuth wraps Auth as a go-zero route middleware.
-func RestAuth(tokens authusecase.TokenUseCase, cfg config.AuthConf) rest.Middleware {
- return func(next http.HandlerFunc) http.HandlerFunc {
- return Auth(tokens, cfg, next)
- }
-}
-
-// RestMemberAuth wraps MemberAuth as a go-zero route middleware.
-func RestMemberAuth(tokens authusecase.TokenUseCase, cfg config.AuthConf) rest.Middleware {
- return func(next http.HandlerFunc) http.HandlerFunc {
- return MemberAuth(tokens, cfg, next)
- }
-}
-
-func resolveActor(
- r *http.Request,
- tokens authusecase.TokenUseCase,
- cfg config.AuthConf,
- authorizationHeader string,
- missingMessage string,
-) (authctx.Actor, error) {
- if cfg.DevHeaderFallback {
- tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-ID"))
- uid := strings.TrimSpace(r.Header.Get("X-UID"))
- if tenantID != "" && uid != "" {
- return authctx.Actor{TenantID: tenantID, UID: uid}, nil
- }
- }
- raw := bearerToken(authorizationHeader)
- if raw == "" {
- return authctx.Actor{}, app.For(code.Auth).AuthUnauthorized(missingMessage)
- }
- if tokens == nil {
- return authctx.Actor{}, app.For(code.Auth).SysNotImplemented("token usecase is not configured")
- }
- claims, err := tokens.ParseAccessToken(r.Context(), raw)
- if err != nil {
- return authctx.Actor{}, err
- }
- return authctx.Actor{TenantID: claims.TenantID, UID: claims.UID, JTI: claims.JTI}, nil
-}
-
-func BearerToken(header string) string {
- return bearerToken(header)
-}
-
-func bearerToken(header string) string {
- const prefix = "Bearer "
- if !strings.HasPrefix(header, prefix) {
- return ""
- }
- return strings.TrimSpace(strings.TrimPrefix(header, prefix))
-}
diff --git a/old/backend/internal/middleware/auth_test.go b/old/backend/internal/middleware/auth_test.go
deleted file mode 100644
index 2a921e6..0000000
--- a/old/backend/internal/middleware/auth_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package middleware
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
-
- "haixun-backend/internal/config"
- "haixun-backend/internal/library/authctx"
-)
-
-func TestMemberAuth_DevHeaderFallback(t *testing.T) {
- called := false
- handler := MemberAuth(nil, config.AuthConf{DevHeaderFallback: true}, func(w http.ResponseWriter, r *http.Request) {
- called = true
- actor, ok := authctx.ActorFromContext(r.Context())
- if !ok || actor.UID != "u1" {
- t.Fatalf("actor = %+v, ok=%v", actor, ok)
- }
- })
-
- req := httptest.NewRequest(http.MethodPost, "/api/v1/ai/chat", nil)
- req.Header.Set("X-Tenant-ID", "default")
- req.Header.Set("X-UID", "u1")
- req.Header.Set("Authorization", "Bearer sk-provider-key")
- rec := httptest.NewRecorder()
- handler(rec, req)
-
- if !called {
- t.Fatal("expected handler to be called via dev headers")
- }
-}
-
-func TestAuth_RequiresAuthorizationBearer(t *testing.T) {
- handler := Auth(nil, config.AuthConf{DevHeaderFallback: false}, func(w http.ResponseWriter, r *http.Request) {
- t.Fatal("handler should not be called")
- })
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
- rec := httptest.NewRecorder()
- handler(rec, req)
-
- if rec.Code != http.StatusUnauthorized {
- t.Fatalf("status = %d, want 401", rec.Code)
- }
-}
diff --git a/old/backend/internal/middleware/authjwt_middleware.go b/old/backend/internal/middleware/authjwt_middleware.go
deleted file mode 100644
index 4d29299..0000000
--- a/old/backend/internal/middleware/authjwt_middleware.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package middleware
-
-import (
- "net/http"
-
- "haixun-backend/internal/config"
- authusecase "haixun-backend/internal/model/auth/domain/usecase"
-)
-
-// AuthJWTMiddleware enforces Bearer member JWT on protected routes.
-// Mounted via @server(middleware: AuthJWT) in generate/api/*.api.
-type AuthJWTMiddleware struct {
- tokens authusecase.TokenUseCase
- cfg config.AuthConf
-}
-
-func NewAuthJWTMiddleware(tokens authusecase.TokenUseCase, cfg config.AuthConf) *AuthJWTMiddleware {
- return &AuthJWTMiddleware{tokens: tokens, cfg: cfg}
-}
-
-func (m *AuthJWTMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
- return Auth(m.tokens, m.cfg, next)
-}
diff --git a/old/backend/internal/middleware/emailverified_middleware.go b/old/backend/internal/middleware/emailverified_middleware.go
deleted file mode 100644
index 5dc746f..0000000
--- a/old/backend/internal/middleware/emailverified_middleware.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package middleware
-
-import (
- "net/http"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/member/domain/entity"
- memberdomain "haixun-backend/internal/model/member/domain/usecase"
- "haixun-backend/internal/response"
-)
-
-var emailVerificationBypass = map[string]map[string]struct{}{
- http.MethodGet: {
- "/api/v1/members/me": {},
- },
- http.MethodPost: {
- "/api/v1/auth/logout": {},
- "/api/v1/auth/verify-email": {},
- "/api/v1/auth/resend-verification-email": {},
- },
-}
-
-// EmailVerifiedMiddleware blocks native members who have not verified email yet.
-// Third-party (oauth) origins skip this gate.
-type EmailVerifiedMiddleware struct {
- members memberdomain.UseCase
-}
-
-func NewEmailVerifiedMiddleware(members memberdomain.UseCase) *EmailVerifiedMiddleware {
- return &EmailVerifiedMiddleware{members: members}
-}
-
-func (m *EmailVerifiedMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- actor, ok := authctx.ActorFromContext(r.Context())
- if !ok {
- next(w, r)
- return
- }
- if emailVerificationBypassed(r.Method, r.URL.Path) {
- next(w, r)
- return
- }
- if m.members == nil {
- response.Write(r.Context(), w, nil, app.For(code.Auth).SysNotImplemented("member usecase is not configured"))
- return
- }
- member, err := m.members.GetByUID(r.Context(), actor.TenantID, actor.UID)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
- if !requiresNativeEmailVerification(member) || member.EmailVerified {
- next(w, r)
- return
- }
- response.Write(r.Context(), w, nil, app.For(code.Auth).AuthForbidden("email verification required"))
- }
-}
-
-func emailVerificationBypassed(method, path string) bool {
- routes, ok := emailVerificationBypass[method]
- if !ok {
- return false
- }
- _, ok = routes[path]
- return ok
-}
-
-func requiresNativeEmailVerification(member *entity.Member) bool {
- if member == nil {
- return false
- }
- return member.Origin == "" || member.Origin == entity.OriginNative
-}
\ No newline at end of file
diff --git a/old/backend/internal/middleware/emailverified_middleware_test.go b/old/backend/internal/middleware/emailverified_middleware_test.go
deleted file mode 100644
index f7d44e6..0000000
--- a/old/backend/internal/middleware/emailverified_middleware_test.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package middleware
-
-import (
- "net/http"
- "testing"
-
- "haixun-backend/internal/model/member/domain/entity"
-)
-
-func TestEmailVerificationBypassed(t *testing.T) {
- if !emailVerificationBypassed(http.MethodPost, "/api/v1/auth/verify-email") {
- t.Fatal("verify-email should bypass")
- }
- if emailVerificationBypassed(http.MethodGet, "/api/v1/settings") {
- t.Fatal("settings should not bypass")
- }
-}
-
-func TestRequiresNativeEmailVerification(t *testing.T) {
- if !requiresNativeEmailVerification(&entity.Member{Origin: entity.OriginNative}) {
- t.Fatal("native should require verification")
- }
- if requiresNativeEmailVerification(&entity.Member{Origin: entity.OriginOAuth, EmailVerified: false}) {
- t.Fatal("oauth should skip verification")
- }
-}
\ No newline at end of file
diff --git a/old/backend/internal/middleware/memberauth_middleware.go b/old/backend/internal/middleware/memberauth_middleware.go
deleted file mode 100644
index bbf26cf..0000000
--- a/old/backend/internal/middleware/memberauth_middleware.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package middleware
-
-import (
- "net/http"
-
- "haixun-backend/internal/config"
- authusecase "haixun-backend/internal/model/auth/domain/usecase"
-)
-
-// MemberAuthMiddleware enforces member JWT from X-Member-Authorization.
-// AI routes keep Authorization for provider API keys.
-// Mounted via @server(middleware: MemberAuth) in generate/api/ai.api.
-type MemberAuthMiddleware struct {
- tokens authusecase.TokenUseCase
- cfg config.AuthConf
-}
-
-func NewMemberAuthMiddleware(tokens authusecase.TokenUseCase, cfg config.AuthConf) *MemberAuthMiddleware {
- return &MemberAuthMiddleware{tokens: tokens, cfg: cfg}
-}
-
-func (m *MemberAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
- return MemberAuth(m.tokens, m.cfg, next)
-}
diff --git a/old/backend/internal/middleware/permissionrbac_middleware.go b/old/backend/internal/middleware/permissionrbac_middleware.go
deleted file mode 100644
index 870afbc..0000000
--- a/old/backend/internal/middleware/permissionrbac_middleware.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package middleware
-
-import (
- "net/http"
-
- "haixun-backend/internal/library/authctx"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/permmatch"
- memberdomain "haixun-backend/internal/model/member/domain/usecase"
- permissiondomain "haixun-backend/internal/model/permission/domain/usecase"
- "haixun-backend/internal/response"
-)
-
-// PermissionRBACMiddleware enforces catalog permissions for authenticated members.
-// It is composed AFTER AuthJWT / MemberAuth (see svc.NewServiceContext) so the
-// actor is already in context.
-//
-// Route coverage: defaultPermissions uses trailing-* patterns
-// (/api/v1/brands/*, /api/v1/personas/*, /api/v1/placement/topics/*, ...) that
-// cover nested routes (copy-missions, scan-posts, knowledge-graph, etc.).
-// permission.Me also falls back to the default user permission set when a tenant
-// has no seeded role_permissions, so members are never locked out by a missing seed.
-type PermissionRBACMiddleware struct {
- members memberdomain.UseCase
- permissions permissiondomain.UseCase
-}
-
-func NewPermissionRBACMiddleware(
- members memberdomain.UseCase,
- permissions permissiondomain.UseCase,
-) *PermissionRBACMiddleware {
- return &PermissionRBACMiddleware{members: members, permissions: permissions}
-}
-
-func (m *PermissionRBACMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- actor, ok := authctx.ActorFromContext(r.Context())
- if !ok {
- response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("missing actor"))
- return
- }
- if m.members == nil || m.permissions == nil {
- response.Write(r.Context(), w, nil, app.For(code.Permission).SysNotImplemented("permission rbac is not configured"))
- return
- }
-
- member, err := m.members.GetByUID(r.Context(), actor.TenantID, actor.UID)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
-
- me, err := m.permissions.Me(r.Context(), member, false)
- if err != nil {
- response.Write(r.Context(), w, nil, err)
- return
- }
- if !permmatch.RequestAllowed(me.Permissions, r.Method, r.URL.Path) {
- response.Write(r.Context(), w, nil, app.For(code.Permission).AuthForbidden("permission denied"))
- return
- }
-
- next(w, r)
- }
-}
diff --git a/old/backend/internal/middleware/workersecret_middleware.go b/old/backend/internal/middleware/workersecret_middleware.go
deleted file mode 100644
index 02995f2..0000000
--- a/old/backend/internal/middleware/workersecret_middleware.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package middleware
-
-import (
- "crypto/subtle"
- "net/http"
- "strings"
-
- "haixun-backend/internal/config"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/response"
-)
-
-const WorkerSecretHeader = "X-Worker-Secret"
-
-// WorkerSecretMiddleware enforces X-Worker-Secret on internal worker routes.
-// The secret is REQUIRED: when InternalWorker.Secret is empty the middleware
-// rejects every request (fail closed) instead of passing through, so the
-// internal worker endpoints can never be exposed unauthenticated.
-type WorkerSecretMiddleware struct {
- cfg config.InternalWorkerConf
-}
-
-func NewWorkerSecretMiddleware(cfg config.InternalWorkerConf) *WorkerSecretMiddleware {
- return &WorkerSecretMiddleware{cfg: cfg}
-}
-
-func (m *WorkerSecretMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- secret := strings.TrimSpace(m.cfg.Secret)
- if secret == "" {
- response.Write(r.Context(), w, nil, app.For(code.Auth).AuthForbidden("internal worker secret is not configured"))
- return
- }
- provided := r.Header.Get(WorkerSecretHeader)
- if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
- response.Write(r.Context(), w, nil, app.For(code.Auth).AuthUnauthorized("invalid worker secret"))
- return
- }
- next(w, r)
- }
-}
diff --git a/old/backend/internal/model/ai/domain/enum/provider.go b/old/backend/internal/model/ai/domain/enum/provider.go
deleted file mode 100644
index 44a1913..0000000
--- a/old/backend/internal/model/ai/domain/enum/provider.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package enum
-
-type ProviderID string
-
-const (
- ProviderOpenCode ProviderID = "opencode-go"
- ProviderXAI ProviderID = "xai"
-)
diff --git a/old/backend/internal/model/ai/domain/usecase/ai.go b/old/backend/internal/model/ai/domain/usecase/ai.go
deleted file mode 100644
index 024e657..0000000
--- a/old/backend/internal/model/ai/domain/usecase/ai.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/ai/domain/enum"
-)
-
-type Message struct {
- Role string
- Content string
-}
-
-type Credential struct {
- APIKey string
-}
-
-type GenerateRequest struct {
- Provider enum.ProviderID
- Model string
- Credential Credential
- System string
- Messages []Message
- Temperature *float64
- MaxTokens *int
-}
-
-type GenerateResult struct {
- Text string
- FinishReason string
-}
-
-type StreamEvent struct {
- Type string `json:"type"`
- Text string `json:"text,omitempty"`
- FinishReason string `json:"finish_reason,omitempty"`
- Error string `json:"error,omitempty"`
-}
-
-type ProviderOption struct {
- ID string
- Label string
- Streams bool
-}
-
-type ProviderModels struct {
- ID string
- Label string
- Models []string
- Streams bool
- Error string
-}
-
-type Provider interface {
- ID() enum.ProviderID
- ListModels(ctx context.Context, credential Credential) ([]string, error)
- GenerateText(ctx context.Context, req GenerateRequest) (*GenerateResult, error)
- StreamText(ctx context.Context, req GenerateRequest) (<-chan StreamEvent, error)
-}
-
-type UseCase interface {
- ListProviders(ctx context.Context) []ProviderOption
- ListProviderModels(ctx context.Context, provider enum.ProviderID, credential Credential) ProviderModels
- GenerateText(ctx context.Context, req GenerateRequest) (*GenerateResult, error)
- StreamText(ctx context.Context, req GenerateRequest) (<-chan StreamEvent, error)
-}
diff --git a/old/backend/internal/model/ai/provider/openai_compatible.go b/old/backend/internal/model/ai/provider/openai_compatible.go
deleted file mode 100644
index 91667c4..0000000
--- a/old/backend/internal/model/ai/provider/openai_compatible.go
+++ /dev/null
@@ -1,404 +0,0 @@
-package provider
-
-import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "sort"
- "strings"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/ai/domain/enum"
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-type OpenAICompatible struct {
- id enum.ProviderID
- baseURL string
- client *http.Client
-}
-
-func NewOpenAICompatible(id enum.ProviderID, baseURL string) *OpenAICompatible {
- return &OpenAICompatible{
- id: id,
- baseURL: strings.TrimRight(baseURL, "/"),
- client: &http.Client{Timeout: 10 * time.Minute},
- }
-}
-
-func (p *OpenAICompatible) ID() enum.ProviderID {
- return p.id
-}
-
-func (p *OpenAICompatible) ListModels(ctx context.Context, credential domai.Credential) ([]string, error) {
- if strings.TrimSpace(credential.APIKey) == "" {
- return nil, app.For(code.AI).InputMissingRequired("missing AI provider token")
- }
-
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/models", nil)
- if err != nil {
- return nil, err
- }
- httpReq.Header.Set("Authorization", "Bearer "+credential.APIKey)
- httpReq.Header.Set("Accept", "application/json")
-
- resp, err := p.client.Do(httpReq)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
- if err != nil {
- return nil, err
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, providerHTTPError("AI provider models request failed", resp.StatusCode, resp.Status)
- }
-
- var payload openAIModelsResponse
- if err := json.Unmarshal(data, &payload); err != nil {
- return nil, app.For(code.AI).SvcThirdParty("failed to parse AI provider models response")
- }
-
- 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)
- return models, nil
-}
-
-func (p *OpenAICompatible) GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error) {
- if strings.TrimSpace(req.Credential.APIKey) == "" {
- return nil, app.For(code.AI).InputMissingRequired("missing AI provider token")
- }
-
- // Stream even for one-shot generation: long outputs (e.g. knowledge graph /
- // research map) can exceed the provider's Cloudflare ~100s origin timeout in
- // non-stream mode and surface as HTTP 524. Streaming keeps bytes flowing and
- // we accumulate them back into a single result.
- body := p.buildChatCompletionRequest(req, true)
- payload, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
-
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(payload))
- if err != nil {
- return nil, err
- }
- httpReq.Header.Set("Authorization", "Bearer "+req.Credential.APIKey)
- httpReq.Header.Set("Content-Type", "application/json")
- httpReq.Header.Set("Accept", "text/event-stream")
-
- resp, err := p.client.Do(httpReq)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
- return nil, providerHTTPError("AI provider request failed", resp.StatusCode, resp.Status)
- }
-
- return accumulateChatStream(resp.Body)
-}
-
-// accumulateChatStream consumes an OpenAI-compatible SSE stream and folds the
-// chunks back into a single GenerateResult. Final answer content and reasoning
-// are kept apart so reasoning never gets prepended to (and corrupts) the parsed
-// answer; reasoning is only used as a fallback when no content was emitted.
-func accumulateChatStream(body io.Reader) (*domai.GenerateResult, error) {
- scanner := bufio.NewScanner(body)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
-
- var content strings.Builder
- var reasoning strings.Builder
- finishReason := ""
-
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || !strings.HasPrefix(line, "data:") {
- continue
- }
- data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if data == "[DONE]" {
- break
- }
- var chunk openAIStreamChunk
- if err := json.Unmarshal([]byte(data), &chunk); err != nil {
- return nil, app.For(code.AI).SvcThirdParty("failed to parse AI provider chat response")
- }
- for _, choice := range chunk.Choices {
- delta := choice.Delta
- if delta.Content != "" {
- content.WriteString(delta.Content)
- } else if delta.Text != "" {
- content.WriteString(delta.Text)
- }
- if delta.ReasoningContent != "" {
- reasoning.WriteString(delta.ReasoningContent)
- } else if delta.Reasoning != "" {
- reasoning.WriteString(delta.Reasoning)
- }
- if choice.FinishReason != "" {
- finishReason = choice.FinishReason
- }
- }
- }
- if err := scanner.Err(); err != nil {
- return nil, app.For(code.AI).SvcThirdParty("AI provider stream read failed: " + err.Error())
- }
-
- text := content.String()
- if strings.TrimSpace(text) == "" {
- text = reasoning.String()
- }
- if strings.TrimSpace(text) == "" {
- return nil, app.For(code.AI).SvcThirdParty("AI provider returned no choices")
- }
- return &domai.GenerateResult{
- Text: text,
- FinishReason: finishReason,
- }, nil
-}
-
-func (p *OpenAICompatible) StreamText(ctx context.Context, req domai.GenerateRequest) (<-chan domai.StreamEvent, error) {
- if strings.TrimSpace(req.Credential.APIKey) == "" {
- return nil, app.For(code.AI).InputMissingRequired("missing AI provider token")
- }
-
- body := p.buildChatCompletionRequest(req, true)
- payload, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
-
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(payload))
- if err != nil {
- return nil, err
- }
- httpReq.Header.Set("Authorization", "Bearer "+req.Credential.APIKey)
- httpReq.Header.Set("Content-Type", "application/json")
- httpReq.Header.Set("Accept", "text/event-stream")
-
- resp, err := p.client.Do(httpReq)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- defer resp.Body.Close()
- _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
- return nil, providerHTTPError("AI provider request failed", resp.StatusCode, resp.Status)
- }
-
- out := make(chan domai.StreamEvent)
- go func() {
- defer close(out)
- defer resp.Body.Close()
- parseOpenAIStream(resp.Body, out)
- }()
- return out, nil
-}
-
-type chatCompletionRequest struct {
- Model string `json:"model"`
- Messages []chatMessage `json:"messages"`
- Stream bool `json:"stream"`
- Temperature *float64 `json:"temperature,omitempty"`
- MaxTokens *int `json:"max_tokens,omitempty"`
- Thinking *thinkingConfig `json:"thinking,omitempty"`
-}
-
-type thinkingConfig struct {
- Type string `json:"type,omitempty"`
-}
-
-type assistantMessage struct {
- Content json.RawMessage `json:"content"`
- ReasoningContent string `json:"reasoning_content"`
- Reasoning string `json:"reasoning"`
-}
-
-type openAIChatResponse struct {
- Choices []struct {
- Message assistantMessage `json:"message"`
- FinishReason string `json:"finish_reason"`
- } `json:"choices"`
-}
-
-func (p *OpenAICompatible) buildChatCompletionRequest(req domai.GenerateRequest, stream bool) chatCompletionRequest {
- return chatCompletionRequest{
- Model: req.Model,
- Messages: toChatMessages(req),
- Stream: stream,
- Temperature: normalizeTemperature(p.id, req.Model, req.Temperature),
- MaxTokens: normalizeMaxTokens(p.id, req.Model, req.MaxTokens),
- Thinking: normalizeThinking(p.id, req.Model),
- }
-}
-
-func extractAssistantText(msg assistantMessage) string {
- if text := parseMessageContent(msg.Content); strings.TrimSpace(text) != "" {
- return text
- }
- if strings.TrimSpace(msg.ReasoningContent) != "" {
- return msg.ReasoningContent
- }
- return msg.Reasoning
-}
-
-func parseMessageContent(raw json.RawMessage) string {
- if len(raw) == 0 || string(raw) == "null" {
- return ""
- }
-
- var text string
- if err := json.Unmarshal(raw, &text); err == nil {
- return text
- }
-
- var parts []struct {
- Text string `json:"text"`
- }
- if err := json.Unmarshal(raw, &parts); err == nil {
- var b strings.Builder
- for _, part := range parts {
- if part.Text != "" {
- b.WriteString(part.Text)
- }
- }
- return b.String()
- }
-
- return ""
-}
-
-type chatMessage struct {
- Role string `json:"role"`
- Content string `json:"content"`
-}
-
-func toChatMessages(req domai.GenerateRequest) []chatMessage {
- messages := make([]chatMessage, 0, len(req.Messages)+1)
- if strings.TrimSpace(req.System) != "" {
- messages = append(messages, chatMessage{Role: "system", Content: req.System})
- }
- for _, msg := range req.Messages {
- messages = append(messages, chatMessage{Role: msg.Role, Content: msg.Content})
- }
- return messages
-}
-
-func normalizeTemperature(provider enum.ProviderID, model string, requested *float64) *float64 {
- if provider == enum.ProviderOpenCode && strings.HasPrefix(model, "kimi-") {
- v := 1.0
- return &v
- }
- return requested
-}
-
-func normalizeMaxTokens(provider enum.ProviderID, model string, requested *int) *int {
- if provider != enum.ProviderOpenCode || !strings.HasPrefix(model, "deepseek-") {
- return requested
- }
-
- // DeepSeek V4 thinking can consume the full budget before content appears.
- const minDeepSeekTokens = 2048
- if requested == nil || *requested < minDeepSeekTokens {
- v := minDeepSeekTokens
- return &v
- }
- return requested
-}
-
-func normalizeThinking(provider enum.ProviderID, model string) *thinkingConfig {
- // DeepSeek V4 on OpenCode Go defaults to thinking mode and may spend the entire
- // max_tokens budget on reasoning_content before emitting content.
- if provider == enum.ProviderOpenCode && strings.HasPrefix(model, "deepseek-") {
- return &thinkingConfig{Type: "disabled"}
- }
- return nil
-}
-
-func streamDeltaText(delta streamDelta) string {
- if delta.Content != "" {
- return delta.Content
- }
- if delta.Text != "" {
- return delta.Text
- }
- if delta.ReasoningContent != "" {
- return delta.ReasoningContent
- }
- return delta.Reasoning
-}
-
-func parseOpenAIStream(body io.Reader, out chan<- domai.StreamEvent) {
- scanner := bufio.NewScanner(body)
- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || !strings.HasPrefix(line, "data:") {
- continue
- }
- data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if data == "[DONE]" {
- out <- domai.StreamEvent{Type: "done"}
- return
- }
- var chunk openAIStreamChunk
- if err := json.Unmarshal([]byte(data), &chunk); err != nil {
- out <- domai.StreamEvent{Type: "error", Error: "failed to parse AI stream payload"}
- return
- }
- for _, choice := range chunk.Choices {
- if text := streamDeltaText(choice.Delta); text != "" {
- out <- domai.StreamEvent{Type: "delta", Text: text}
- }
- if choice.FinishReason != "" {
- out <- domai.StreamEvent{Type: "done", FinishReason: choice.FinishReason}
- return
- }
- }
- }
- if err := scanner.Err(); err != nil {
- out <- domai.StreamEvent{Type: "error", Error: err.Error()}
- }
-}
-
-type streamDelta struct {
- Content string `json:"content"`
- Text string `json:"text"`
- ReasoningContent string `json:"reasoning_content"`
- Reasoning string `json:"reasoning"`
-}
-
-type openAIStreamChunk struct {
- Choices []struct {
- Delta streamDelta `json:"delta"`
- FinishReason string `json:"finish_reason"`
- } `json:"choices"`
-}
-
-type openAIModelsResponse struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
-}
-
-func providerHTTPError(prefix string, statusCode int, statusLine string) error {
- return app.For(code.AI).SvcThirdParty(fmt.Sprintf("%s: HTTP %d %s", prefix, statusCode, statusLine))
-}
diff --git a/old/backend/internal/model/ai/provider/openai_compatible_test.go b/old/backend/internal/model/ai/provider/openai_compatible_test.go
deleted file mode 100644
index 9eeef0c..0000000
--- a/old/backend/internal/model/ai/provider/openai_compatible_test.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package provider
-
-import (
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "haixun-backend/internal/model/ai/domain/enum"
- domai "haixun-backend/internal/model/ai/domain/usecase"
-)
-
-func TestOpenAICompatible_ListModels(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodGet || r.URL.Path != "/models" {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
- t.Fatalf("unexpected auth header: %s", got)
- }
- _, _ = w.Write([]byte(`{"data":[{"id":"grok-3-fast"},{"id":"grok-3"},{"id":""}]}`))
- }))
- defer server.Close()
-
- p := NewOpenAICompatible(enum.ProviderXAI, server.URL)
- models, err := p.ListModels(context.Background(), domai.Credential{APIKey: "test-token"})
- if err != nil {
- t.Fatalf("ListModels() error = %v", err)
- }
-
- want := []string{"grok-3", "grok-3-fast"}
- if len(models) != len(want) {
- t.Fatalf("models = %v, want %v", models, want)
- }
- for i, id := range want {
- if models[i] != id {
- t.Fatalf("models[%d] = %s, want %s", i, models[i], id)
- }
- }
-}
-
-func TestOpenAICompatible_ListModels_MissingToken(t *testing.T) {
- p := NewOpenAICompatible(enum.ProviderXAI, "https://example.com")
- _, err := p.ListModels(context.Background(), domai.Credential{})
- if err == nil {
- t.Fatal("expected error for missing token")
- }
-}
-
-func TestOpenAICompatible_GenerateText_UsesContent(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost || r.URL.Path != "/chat/completions" {
- t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
- }
- body, _ := io.ReadAll(r.Body)
- var req chatCompletionRequest
- if err := json.Unmarshal(body, &req); err != nil {
- t.Fatalf("unmarshal request: %v", err)
- }
- if !req.Stream {
- t.Fatal("expected stream request to avoid upstream 524 on long generations")
- }
- if req.Thinking == nil || req.Thinking.Type != "disabled" {
- t.Fatalf("thinking = %+v, want disabled for deepseek on opencode", req.Thinking)
- }
- if req.MaxTokens == nil || *req.MaxTokens < 2048 {
- t.Fatalf("max_tokens = %+v, want >= 2048 for deepseek on opencode", req.MaxTokens)
- }
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
- _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}]}\n\n"))
- _, _ = w.Write([]byte("data: [DONE]\n\n"))
- }))
- defer server.Close()
-
- p := NewOpenAICompatible(enum.ProviderOpenCode, server.URL)
- maxTokens := 256
- result, err := p.GenerateText(context.Background(), domai.GenerateRequest{
- Provider: enum.ProviderOpenCode,
- Model: "deepseek-v4-flash",
- Credential: domai.Credential{APIKey: "test-token"},
- Messages: []domai.Message{{Role: "user", Content: "hi"}},
- MaxTokens: &maxTokens,
- })
- if err != nil {
- t.Fatalf("GenerateText() error = %v", err)
- }
- if result.Text != "hello" || result.FinishReason != "stop" {
- t.Fatalf("result = %+v, want text=hello finish_reason=stop", result)
- }
-}
-
-func TestOpenAICompatible_GenerateText_FallsBackToReasoningContent(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"thinking only\"},\"finish_reason\":\"length\"}]}\n\n"))
- _, _ = w.Write([]byte("data: [DONE]\n\n"))
- }))
- defer server.Close()
-
- p := NewOpenAICompatible(enum.ProviderXAI, server.URL)
- result, err := p.GenerateText(context.Background(), domai.GenerateRequest{
- Model: "deepseek-test",
- Credential: domai.Credential{APIKey: "test-token"},
- Messages: []domai.Message{{Role: "user", Content: "hi"}},
- })
- if err != nil {
- t.Fatalf("GenerateText() error = %v", err)
- }
- if result.Text != "thinking only" {
- t.Fatalf("result.Text = %q, want thinking only", result.Text)
- }
-}
-
-func TestOpenAICompatible_StreamText_ParsesReasoningContent(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n\n"))
- _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"answer\"},\"finish_reason\":\"stop\"}]}\n\n"))
- _, _ = w.Write([]byte("data: [DONE]\n\n"))
- }))
- defer server.Close()
-
- p := NewOpenAICompatible(enum.ProviderXAI, server.URL)
- stream, err := p.StreamText(context.Background(), domai.GenerateRequest{
- Model: "deepseek-test",
- Credential: domai.Credential{APIKey: "test-token"},
- Messages: []domai.Message{{Role: "user", Content: "hi"}},
- })
- if err != nil {
- t.Fatalf("StreamText() error = %v", err)
- }
-
- var deltas []string
- var finishReason string
- for event := range stream {
- switch event.Type {
- case "delta":
- deltas = append(deltas, event.Text)
- case "done":
- finishReason = event.FinishReason
- case "error":
- t.Fatalf("stream error: %s", event.Error)
- }
- }
-
- if strings.Join(deltas, "") != "thinkanswer" {
- t.Fatalf("deltas = %v, want thinkanswer", deltas)
- }
- if finishReason != "stop" {
- t.Fatalf("finishReason = %q, want stop", finishReason)
- }
-}
-
-func TestBuildChatCompletionRequest_DisablesThinkingForOpenCodeDeepSeek(t *testing.T) {
- p := NewOpenAICompatible(enum.ProviderOpenCode, "https://example.com")
- maxTokens := 128
- body := p.buildChatCompletionRequest(domai.GenerateRequest{
- Provider: enum.ProviderOpenCode,
- Model: "deepseek-v4-flash",
- Messages: []domai.Message{{Role: "user", Content: "hi"}},
- MaxTokens: &maxTokens,
- }, true)
-
- if body.Thinking == nil || body.Thinking.Type != "disabled" {
- t.Fatalf("thinking = %+v, want disabled", body.Thinking)
- }
- if body.MaxTokens == nil || *body.MaxTokens < 2048 {
- t.Fatalf("max_tokens = %+v, want >= 2048", body.MaxTokens)
- }
-}
-
-func TestExtractAssistantText_ContentArray(t *testing.T) {
- msg := assistantMessage{
- Content: json.RawMessage(`[{"type":"text","text":"array content"}]`),
- }
- if got := extractAssistantText(msg); got != "array content" {
- t.Fatalf("extractAssistantText() = %q, want array content", got)
- }
-}
diff --git a/old/backend/internal/model/ai/usecase/provider_map.go b/old/backend/internal/model/ai/usecase/provider_map.go
deleted file mode 100644
index 6eb350a..0000000
--- a/old/backend/internal/model/ai/usecase/provider_map.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package usecase
-
-import (
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/ai/domain/enum"
-)
-
-func MapWorkerProvider(provider string) (enum.ProviderID, error) {
- switch strings.TrimSpace(provider) {
- case string(enum.ProviderOpenCode):
- return enum.ProviderOpenCode, nil
- case string(enum.ProviderXAI):
- return enum.ProviderXAI, nil
- default:
- return "", app.For(code.AI).InputInvalidFormat("目前僅支援 opencode-go 與 xai,請在 AI 設定調整 provider")
- }
-}
diff --git a/old/backend/internal/model/ai/usecase/usecase.go b/old/backend/internal/model/ai/usecase/usecase.go
deleted file mode 100644
index f3cb4ef..0000000
--- a/old/backend/internal/model/ai/usecase/usecase.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package usecase
-
-import (
- "context"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/ai/domain/enum"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- "haixun-backend/internal/model/ai/provider"
-)
-
-type UseCase = domai.UseCase
-
-type providerMeta struct {
- ID string
- Label string
- Streams bool
-}
-
-type aiUseCase struct {
- providers map[enum.ProviderID]domai.Provider
- catalog []providerMeta
-}
-
-func NewUseCase() UseCase {
- providers := map[enum.ProviderID]domai.Provider{
- enum.ProviderOpenCode: provider.NewOpenAICompatible(enum.ProviderOpenCode, "https://opencode.ai/zen/go/v1"),
- enum.ProviderXAI: provider.NewOpenAICompatible(enum.ProviderXAI, "https://api.x.ai/v1"),
- }
- return &aiUseCase{
- providers: providers,
- catalog: []providerMeta{
- {ID: string(enum.ProviderOpenCode), Label: "OpenCode Go", Streams: true},
- {ID: string(enum.ProviderXAI), Label: "Grok (xAI)", Streams: true},
- },
- }
-}
-
-func (u *aiUseCase) ListProviders(ctx context.Context) []domai.ProviderOption {
- options := make([]domai.ProviderOption, 0, len(u.catalog))
- for _, meta := range u.catalog {
- options = append(options, domai.ProviderOption{
- ID: meta.ID,
- Label: meta.Label,
- Streams: meta.Streams,
- })
- }
- return options
-}
-
-func (u *aiUseCase) ListProviderModels(ctx context.Context, providerID enum.ProviderID, credential domai.Credential) domai.ProviderModels {
- meta, ok := u.meta(providerID)
- if !ok {
- return domai.ProviderModels{
- Error: app.For(code.AI).InputInvalidFormat("unsupported AI provider").Error(),
- }
- }
-
- result := domai.ProviderModels{
- ID: meta.ID,
- Label: meta.Label,
- Models: []string{},
- Streams: meta.Streams,
- }
-
- p, ok := u.providers[providerID]
- if !ok {
- result.Error = app.For(code.AI).InputInvalidFormat("unsupported AI provider").Error()
- return result
- }
-
- models, err := p.ListModels(ctx, credential)
- if err != nil {
- if appErr := app.FromError(err); appErr != nil {
- result.Error = appErr.Error()
- } else {
- result.Error = err.Error()
- }
- return result
- }
-
- result.Models = models
- return result
-}
-
-func (u *aiUseCase) GenerateText(ctx context.Context, req domai.GenerateRequest) (*domai.GenerateResult, error) {
- p, err := u.resolve(req.Provider)
- if err != nil {
- return nil, err
- }
- return p.GenerateText(ctx, req)
-}
-
-func (u *aiUseCase) StreamText(ctx context.Context, req domai.GenerateRequest) (<-chan domai.StreamEvent, error) {
- p, err := u.resolve(req.Provider)
- if err != nil {
- return nil, err
- }
- return p.StreamText(ctx, req)
-}
-
-func (u *aiUseCase) meta(id enum.ProviderID) (providerMeta, bool) {
- for _, meta := range u.catalog {
- if meta.ID == string(id) {
- return meta, true
- }
- }
- return providerMeta{}, false
-}
-
-func (u *aiUseCase) resolve(id enum.ProviderID) (domai.Provider, error) {
- p, ok := u.providers[id]
- if !ok {
- return nil, app.For(code.AI).InputInvalidFormat("unsupported AI provider")
- }
- return p, nil
-}
diff --git a/old/backend/internal/model/auth/domain/repository/token_revoke.go b/old/backend/internal/model/auth/domain/repository/token_revoke.go
deleted file mode 100644
index cb1d2c9..0000000
--- a/old/backend/internal/model/auth/domain/repository/token_revoke.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package repository
-
-import (
- "context"
- "time"
-)
-
-type TokenRevokeStore interface {
- SavePair(ctx context.Context, accessJTI, refreshJTI string, accessTTL, refreshTTL time.Duration) error
- GetPairedJTI(ctx context.Context, jti string) (string, error)
- DeletePair(ctx context.Context, accessJTI, refreshJTI string) error
- Blacklist(ctx context.Context, jti string, ttl time.Duration) error
- IsBlacklisted(ctx context.Context, jti string) (bool, error)
-}
diff --git a/old/backend/internal/model/auth/domain/usecase/token.go b/old/backend/internal/model/auth/domain/usecase/token.go
deleted file mode 100644
index 85a72d6..0000000
--- a/old/backend/internal/model/auth/domain/usecase/token.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package usecase
-
-import "context"
-
-type TokenType string
-
-const (
- TokenTypeAccess TokenType = "access"
- TokenTypeRefresh TokenType = "refresh"
-)
-
-type TokenPair struct {
- AccessToken string
- RefreshToken string
- ExpiresIn int64
- TokenType string
- UID string
-}
-
-type IssuePairRequest struct {
- TenantID string
- UID string
- AuthGen int64
-}
-
-type AccessClaims struct {
- TenantID string
- UID string
- AuthGen int64
- JTI string
-}
-
-type LogoutRequest struct {
- AccessToken string
-}
-
-type TokenUseCase interface {
- IssuePair(ctx context.Context, req IssuePairRequest) (*TokenPair, error)
- Refresh(ctx context.Context, refreshToken string) (*TokenPair, error)
- Logout(ctx context.Context, req LogoutRequest) error
- ParseAccessToken(ctx context.Context, accessToken string) (*AccessClaims, error)
-}
diff --git a/old/backend/internal/model/auth/repository/token_revoke_redis.go b/old/backend/internal/model/auth/repository/token_revoke_redis.go
deleted file mode 100644
index 4235423..0000000
--- a/old/backend/internal/model/auth/repository/token_revoke_redis.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package repository
-
-import (
- "context"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- domrepo "haixun-backend/internal/model/auth/domain/repository"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-const (
- jwtPairPrefix = "auth:jwt:pair:"
- jwtBlacklistPrefix = "auth:jwt:blacklist:"
-)
-
-type redisTokenRevokeStore struct {
- client *goredis.Client
-}
-
-func NewRedisTokenRevokeStore(client *goredis.Client) domrepo.TokenRevokeStore {
- return &redisTokenRevokeStore{client: client}
-}
-
-func (s *redisTokenRevokeStore) SavePair(ctx context.Context, accessJTI, refreshJTI string, accessTTL, refreshTTL time.Duration) error {
- if err := s.requireRedis(); err != nil {
- return err
- }
- if accessJTI == "" || refreshJTI == "" {
- return app.For(code.Auth).InputMissingRequired("jwt pair jti is required")
- }
- if err := s.client.Set(ctx, jwtPairPrefix+accessJTI, refreshJTI, minTTL(accessTTL)).Err(); err != nil {
- return err
- }
- return s.client.Set(ctx, jwtPairPrefix+refreshJTI, accessJTI, minTTL(refreshTTL)).Err()
-}
-
-func (s *redisTokenRevokeStore) GetPairedJTI(ctx context.Context, jti string) (string, error) {
- if err := s.requireRedis(); err != nil {
- return "", err
- }
- value, err := s.client.Get(ctx, jwtPairPrefix+jti).Result()
- if err == goredis.Nil {
- return "", nil
- }
- return value, err
-}
-
-func (s *redisTokenRevokeStore) DeletePair(ctx context.Context, accessJTI, refreshJTI string) error {
- if err := s.requireRedis(); err != nil {
- return err
- }
- keys := make([]string, 0, 2)
- if accessJTI != "" {
- keys = append(keys, jwtPairPrefix+accessJTI)
- }
- if refreshJTI != "" {
- keys = append(keys, jwtPairPrefix+refreshJTI)
- }
- if len(keys) == 0 {
- return nil
- }
- return s.client.Del(ctx, keys...).Err()
-}
-
-func (s *redisTokenRevokeStore) Blacklist(ctx context.Context, jti string, ttl time.Duration) error {
- if err := s.requireRedis(); err != nil {
- return err
- }
- if jti == "" {
- return app.For(code.Auth).InputMissingRequired("jti is required")
- }
- return s.client.Set(ctx, jwtBlacklistPrefix+jti, "1", minTTL(ttl)).Err()
-}
-
-func (s *redisTokenRevokeStore) IsBlacklisted(ctx context.Context, jti string) (bool, error) {
- if err := s.requireRedis(); err != nil {
- return false, err
- }
- count, err := s.client.Exists(ctx, jwtBlacklistPrefix+jti).Result()
- return count > 0, err
-}
-
-func (s *redisTokenRevokeStore) requireRedis() error {
- if s.client == nil {
- return app.For(code.Auth).DBUnavailable("Redis is not configured")
- }
- return nil
-}
-
-func minTTL(ttl time.Duration) time.Duration {
- if ttl < time.Second {
- return time.Second
- }
- return ttl
-}
diff --git a/old/backend/internal/model/auth/usecase/token.go b/old/backend/internal/model/auth/usecase/token.go
deleted file mode 100644
index efb27e9..0000000
--- a/old/backend/internal/model/auth/usecase/token.go
+++ /dev/null
@@ -1,233 +0,0 @@
-package usecase
-
-import (
- "context"
- "errors"
- "fmt"
- "time"
-
- "haixun-backend/internal/config"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- domrepo "haixun-backend/internal/model/auth/domain/repository"
- domusecase "haixun-backend/internal/model/auth/domain/usecase"
-
- "github.com/golang-jwt/jwt/v4"
- "github.com/google/uuid"
-)
-
-type tokenUseCase struct {
- cfg config.AuthConf
- revoke domrepo.TokenRevokeStore
-}
-
-func NewTokenUseCase(cfg config.AuthConf, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase {
- cfg = normalizeConfig(cfg)
- if cfg.AccessSecret == "" || cfg.RefreshSecret == "" {
- // Fail fast: never fall back to a known/hardcoded secret, otherwise tokens
- // could be forged. Provide secrets via env (HAIXUN_JWT_ACCESS_SECRET /
- // HAIXUN_JWT_REFRESH_SECRET) or the dev config file.
- panic("auth: AccessSecret and RefreshSecret must be configured")
- }
- return &tokenUseCase{cfg: cfg, revoke: revoke}
-}
-
-func (u *tokenUseCase) IssuePair(ctx context.Context, req domusecase.IssuePairRequest) (*domusecase.TokenPair, error) {
- if req.TenantID == "" || req.UID == "" {
- return nil, app.For(code.Auth).InputMissingRequired("tenant_id and uid are required")
- }
- access, err := u.sign(req, domusecase.TokenTypeAccess, u.cfg.AccessExpireSeconds, u.cfg.AccessSecret)
- if err != nil {
- return nil, app.For(code.Auth).SysInternal("sign access token failed").WithCause(err)
- }
- refresh, err := u.sign(req, domusecase.TokenTypeRefresh, u.cfg.RefreshExpireSeconds, u.cfg.RefreshSecret)
- if err != nil {
- return nil, app.For(code.Auth).SysInternal("sign refresh token failed").WithCause(err)
- }
- if u.revoke != nil {
- if err := u.revoke.SavePair(ctx, access.jti, refresh.jti, time.Until(access.expiresAt), time.Until(refresh.expiresAt)); err != nil {
- return nil, app.For(code.Auth).DBError("save jwt pair failed").WithCause(err)
- }
- }
- return &domusecase.TokenPair{
- AccessToken: access.raw,
- RefreshToken: refresh.raw,
- ExpiresIn: u.cfg.AccessExpireSeconds,
- TokenType: "Bearer",
- UID: req.UID,
- }, nil
-}
-
-func (u *tokenUseCase) Refresh(ctx context.Context, refreshToken string) (*domusecase.TokenPair, error) {
- if refreshToken == "" {
- return nil, app.For(code.Auth).InputMissingRequired("refresh_token is required")
- }
- claims, err := u.parse(refreshToken, domusecase.TokenTypeRefresh, u.cfg.RefreshSecret)
- if err != nil {
- return nil, app.For(code.Auth).AuthUnauthorized("invalid refresh token").WithCause(err)
- }
- if err := u.ensureNotBlacklisted(ctx, claims.ID); err != nil {
- return nil, err
- }
- pair, err := u.IssuePair(ctx, domusecase.IssuePairRequest{
- TenantID: claims.TenantID,
- UID: claims.UID,
- AuthGen: claims.AuthGen,
- })
- if err != nil {
- return nil, err
- }
- if u.revoke != nil {
- _ = u.revoke.Blacklist(ctx, claims.ID, remainingTTL(claims.expiresAt))
- if accessJTI, err := u.revoke.GetPairedJTI(ctx, claims.ID); err == nil && accessJTI != "" {
- _ = u.revoke.Blacklist(ctx, accessJTI, time.Duration(u.cfg.AccessExpireSeconds)*time.Second)
- _ = u.revoke.DeletePair(ctx, accessJTI, claims.ID)
- }
- }
- return pair, nil
-}
-
-func (u *tokenUseCase) Logout(ctx context.Context, req domusecase.LogoutRequest) error {
- if req.AccessToken == "" {
- return app.For(code.Auth).InputMissingRequired("access token is required")
- }
- if u.revoke == nil {
- return nil
- }
- claims, err := u.parse(req.AccessToken, domusecase.TokenTypeAccess, u.cfg.AccessSecret)
- if err != nil {
- return app.For(code.Auth).AuthUnauthorized("invalid access token").WithCause(err)
- }
- if err := u.revoke.Blacklist(ctx, claims.ID, remainingTTL(claims.expiresAt)); err != nil {
- return app.For(code.Auth).DBError("blacklist access token failed").WithCause(err)
- }
- refreshJTI, err := u.revoke.GetPairedJTI(ctx, claims.ID)
- if err != nil {
- return app.For(code.Auth).DBError("read jwt pair failed").WithCause(err)
- }
- if refreshJTI != "" {
- _ = u.revoke.Blacklist(ctx, refreshJTI, time.Duration(u.cfg.RefreshExpireSeconds)*time.Second)
- }
- return u.revoke.DeletePair(ctx, claims.ID, refreshJTI)
-}
-
-func (u *tokenUseCase) ParseAccessToken(ctx context.Context, accessToken string) (*domusecase.AccessClaims, error) {
- if accessToken == "" {
- return nil, app.For(code.Auth).AuthUnauthorized("missing access token")
- }
- claims, err := u.parse(accessToken, domusecase.TokenTypeAccess, u.cfg.AccessSecret)
- if err != nil {
- return nil, app.For(code.Auth).AuthUnauthorized("invalid access token").WithCause(err)
- }
- if err := u.ensureNotBlacklisted(ctx, claims.ID); err != nil {
- return nil, err
- }
- return &domusecase.AccessClaims{
- TenantID: claims.TenantID,
- UID: claims.UID,
- AuthGen: claims.AuthGen,
- JTI: claims.ID,
- }, nil
-}
-
-func (u *tokenUseCase) ensureNotBlacklisted(ctx context.Context, jti string) error {
- if u.revoke == nil || jti == "" {
- return nil
- }
- blacklisted, err := u.revoke.IsBlacklisted(ctx, jti)
- if err != nil {
- return app.For(code.Auth).DBError("check jwt blacklist failed").WithCause(err)
- }
- if blacklisted {
- return app.For(code.Auth).AuthUnauthorized("token revoked")
- }
- return nil
-}
-
-type jwtClaims struct {
- TenantID string `json:"tenant_id"`
- UID string `json:"uid"`
- Typ string `json:"typ"`
- AuthGen int64 `json:"auth_gen"`
- jwt.RegisteredClaims
-}
-
-type parsedClaims struct {
- TenantID string
- UID string
- AuthGen int64
- ID string
- expiresAt time.Time
-}
-
-type signedToken struct {
- raw string
- jti string
- expiresAt time.Time
-}
-
-func (u *tokenUseCase) sign(req domusecase.IssuePairRequest, typ domusecase.TokenType, expireSec int64, secret string) (*signedToken, error) {
- now := time.Now().UTC()
- expiresAt := now.Add(time.Duration(expireSec) * time.Second)
- jti := uuid.NewString()
- claims := jwtClaims{
- TenantID: req.TenantID,
- UID: req.UID,
- Typ: string(typ),
- AuthGen: req.AuthGen,
- RegisteredClaims: jwt.RegisteredClaims{
- ID: jti,
- IssuedAt: jwt.NewNumericDate(now),
- ExpiresAt: jwt.NewNumericDate(expiresAt),
- },
- }
- raw, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
- if err != nil {
- return nil, err
- }
- return &signedToken{raw: raw, jti: jti, expiresAt: expiresAt}, nil
-}
-
-func (u *tokenUseCase) parse(raw string, want domusecase.TokenType, secret string) (*parsedClaims, error) {
- parsed, err := jwt.ParseWithClaims(raw, &jwtClaims{}, func(t *jwt.Token) (any, error) {
- if t.Method != jwt.SigningMethodHS256 {
- return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
- }
- return []byte(secret), nil
- })
- if err != nil {
- return nil, fmt.Errorf("%w: %w", errInvalidToken, err)
- }
- claims, ok := parsed.Claims.(*jwtClaims)
- if !ok || !parsed.Valid || claims.Typ != string(want) || claims.TenantID == "" || claims.UID == "" {
- return nil, errInvalidToken
- }
- expiresAt := time.Time{}
- if claims.ExpiresAt != nil {
- expiresAt = claims.ExpiresAt.Time
- }
- return &parsedClaims{TenantID: claims.TenantID, UID: claims.UID, AuthGen: claims.AuthGen, ID: claims.ID, expiresAt: expiresAt}, nil
-}
-
-func normalizeConfig(cfg config.AuthConf) config.AuthConf {
- if cfg.AccessExpireSeconds <= 0 {
- cfg.AccessExpireSeconds = 900
- }
- if cfg.RefreshExpireSeconds <= 0 {
- cfg.RefreshExpireSeconds = 2592000
- }
- return cfg
-}
-
-func remainingTTL(expiresAt time.Time) time.Duration {
- if expiresAt.IsZero() {
- return time.Second
- }
- ttl := time.Until(expiresAt)
- if ttl < time.Second {
- return time.Second
- }
- return ttl
-}
-
-var errInvalidToken = errors.New("auth: invalid token")
diff --git a/old/backend/internal/model/brand/domain/entity/brand.go b/old/backend/internal/model/brand/domain/entity/brand.go
deleted file mode 100644
index d5bfeae..0000000
--- a/old/backend/internal/model/brand/domain/entity/brand.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package entity
-
-const CollectionName = "brands"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusDeleted Status = "deleted"
-)
-
-type Brand struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- DisplayName string `bson:"display_name,omitempty"`
- TopicName string `bson:"topic_name,omitempty"`
- SeedQuery string `bson:"seed_query,omitempty"`
- Brief string `bson:"brief,omitempty"`
- ProductBrief string `bson:"product_brief,omitempty"`
- ProductContext string `bson:"product_context,omitempty"`
- ProductID string `bson:"product_id,omitempty"`
- Products []Product `bson:"products,omitempty"`
- TargetAudience string `bson:"target_audience,omitempty"`
- Goals string `bson:"goals,omitempty"`
- ResearchMap ResearchMap `bson:"research_map,omitempty"`
- Status Status `bson:"status"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/brand/domain/entity/product.go b/old/backend/internal/model/brand/domain/entity/product.go
deleted file mode 100644
index d08361b..0000000
--- a/old/backend/internal/model/brand/domain/entity/product.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package entity
-
-type Product struct {
- ID string `bson:"id"`
- Label string `bson:"label"`
- ProductContext string `bson:"product_context"`
- PlacementURL string `bson:"placement_url,omitempty"`
- MatchTags []string `bson:"match_tags,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-func (p Product) HasContext() bool {
- return p.Label != "" || p.ProductContext != ""
-}
diff --git a/old/backend/internal/model/brand/domain/entity/research_map.go b/old/backend/internal/model/brand/domain/entity/research_map.go
deleted file mode 100644
index 96bc5c4..0000000
--- a/old/backend/internal/model/brand/domain/entity/research_map.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package entity
-
-type ResearchItem struct {
- Title string `bson:"title,omitempty" json:"title,omitempty"`
- URL string `bson:"url,omitempty" json:"url,omitempty"`
- Snippet string `bson:"snippet,omitempty" json:"snippet,omitempty"`
- Query string `bson:"query,omitempty" json:"query,omitempty"`
-}
-
-type ResearchMap struct {
- AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"`
- ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"`
- Questions []string `bson:"questions,omitempty" json:"questions,omitempty"`
- Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"`
- Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"`
- ResearchItems []ResearchItem `bson:"research_items,omitempty" json:"research_items,omitempty"`
- ExpandStrategy string `bson:"expand_strategy,omitempty" json:"expand_strategy,omitempty"`
- PatrolKeywords []string `bson:"patrol_keywords,omitempty" json:"patrol_keywords,omitempty"`
-}
-
-func (m ResearchMap) IsEmpty() bool {
- return m.AudienceSummary == "" &&
- m.ContentGoal == "" &&
- len(m.Questions) == 0 &&
- len(m.Pillars) == 0 &&
- len(m.Exclusions) == 0 &&
- len(m.ResearchItems) == 0
-}
diff --git a/old/backend/internal/model/brand/domain/repository/repository.go b/old/backend/internal/model/brand/domain/repository/repository.go
deleted file mode 100644
index 95b61a8..0000000
--- a/old/backend/internal/model/brand/domain/repository/repository.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/brand/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, brand *entity.Brand) (*entity.Brand, error)
- FindByID(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error)
- ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error)
- Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error)
- SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error
- PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error)
- UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error)
- PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error
-}
diff --git a/old/backend/internal/model/brand/domain/usecase/usecase.go b/old/backend/internal/model/brand/domain/usecase/usecase.go
deleted file mode 100644
index 0e7cf4d..0000000
--- a/old/backend/internal/model/brand/domain/usecase/usecase.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/brand/domain/entity"
-)
-
-type ProductSummary struct {
- ID string
- Label string
- ProductContext string
- PlacementURL string
- MatchTags []string
- CreateAt int64
- UpdateAt int64
-}
-
-type BrandSummary struct {
- ID string
- DisplayName string
- TopicName string
- SeedQuery string
- Brief string
- ProductBrief string
- ProductContext string
- ProductID string
- Products []ProductSummary
- TargetAudience string
- Goals string
- ResearchMap entity.ResearchMap
- CreateAt int64
- UpdateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- DisplayName string
- SeedQuery string
- Brief string
- ProductContext string
- ProductBrief string
- TargetAudience string
- Goals string
- ResearchMap *entity.ResearchMap
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- Patch BrandPatch
-}
-
-type BrandPatch struct {
- DisplayName *string
- TopicName *string
- SeedQuery *string
- Brief *string
- ProductBrief *string
- ProductContext *string
- ProductID *string
- TargetAudience *string
- Goals *string
- AudienceSummary *string
- ContentGoal *string
- Questions []string
- QuestionsSet bool
- Pillars []string
- PillarsSet bool
- Exclusions []string
- ExclusionsSet bool
- ResearchMap *entity.ResearchMap
- PatrolKeywords []string
- PatrolKeywordsSet bool
-}
-
-type ListResult struct {
- List []BrandSummary
-}
-
-type ListProductsResult struct {
- List []ProductSummary
-}
-
-type CreateProductRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- Label string
- ProductContext string
- PlacementURL string
- MatchTags []string
-}
-
-type UpdateProductRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- ProductID string
- Label *string
- ProductContext *string
- PlacementURL *string
- PlacementURLSet bool
- MatchTags []string
- MatchTagsSet bool
-}
-
-type UseCase interface {
- List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error)
- Create(ctx context.Context, req CreateRequest) (*BrandSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, brandID string) (*BrandSummary, error)
- Update(ctx context.Context, req UpdateRequest) (*BrandSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, brandID string) error
- ListProducts(ctx context.Context, tenantID, ownerUID, brandID string) (*ListProductsResult, error)
- CreateProduct(ctx context.Context, req CreateProductRequest) (*ProductSummary, error)
- UpdateProduct(ctx context.Context, req UpdateProductRequest) (*ProductSummary, error)
- DeleteProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error
-}
diff --git a/old/backend/internal/model/brand/repository/mongo.go b/old/backend/internal/model/brand/repository/mongo.go
deleted file mode 100644
index 2cd42a2..0000000
--- a/old/backend/internal/model/brand/repository/mongo.go
+++ /dev/null
@@ -1,231 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/brand/domain/entity"
- domrepo "haixun-backend/internal/model/brand/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, brand *entity.Brand) (*entity.Brand, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- brand.CreateAt = now
- brand.UpdateAt = now
- if brand.Status == "" {
- brand.Status = entity.StatusOpen
- }
- _, err := r.collection.InsertOne(ctx, brand)
- if err != nil {
- return nil, err
- }
- return brand, nil
-}
-
-func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error) {
- return r.findOne(ctx, bson.M{
- "_id": strings.TrimSpace(brandID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "status": entity.StatusOpen,
- })
-}
-
-func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Brand
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return r.FindByID(ctx, tenantID, ownerUID, brandID)
- }
- patch["update_at"] = clock.NowUnixNano()
- var out entity.Brand
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": brandID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": patch},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("brand not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"_id": brandID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Brand).ResNotFound("brand not found")
- }
- return nil
-}
-
-func (r *mongoRepository) PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- product.CreateAt = now
- product.UpdateAt = now
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": brandID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{
- "$push": bson.M{"products": product},
- "$set": bson.M{"update_at": now},
- },
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Err()
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("brand not found")
- }
- if err != nil {
- return nil, err
- }
- return &product, nil
-}
-
-func (r *mongoRepository) UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("product patch is empty")
- }
- now := clock.NowUnixNano()
- patch["products.$.update_at"] = now
- set := bson.M{}
- for key, value := range patch {
- set[key] = value
- }
- set["update_at"] = now
-
- var out entity.Brand
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{
- "_id": brandID,
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "status": entity.StatusOpen,
- "products.id": productID,
- },
- bson.M{"$set": set},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("brand or product not found")
- }
- if err != nil {
- return nil, err
- }
- for i := range out.Products {
- if out.Products[i].ID == productID {
- item := out.Products[i]
- return &item, nil
- }
- }
- return nil, app.For(code.Brand).ResNotFound("product not found")
-}
-
-func (r *mongoRepository) PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"_id": brandID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{
- "$pull": bson.M{"products": bson.M{"id": productID}},
- "$set": bson.M{"update_at": now},
- },
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Brand).ResNotFound("brand not found")
- }
- if res.ModifiedCount == 0 {
- return app.For(code.Brand).ResNotFound("product not found")
- }
- return nil
-}
-
-func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Brand, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- var out entity.Brand
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("brand not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/brand/usecase/products.go b/old/backend/internal/model/brand/usecase/products.go
deleted file mode 100644
index 8270001..0000000
--- a/old/backend/internal/model/brand/usecase/products.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/model/brand/domain/entity"
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-func (u *brandUseCase) ListProducts(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.ListProductsResult, error) {
- item, err := u.assertOwned(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return nil, err
- }
- list := make([]domusecase.ProductSummary, 0, len(item.Products))
- for _, product := range item.Products {
- list = append(list, toProductSummary(product))
- }
- return &domusecase.ListProductsResult{List: list}, nil
-}
-
-func (u *brandUseCase) CreateProduct(ctx context.Context, req domusecase.CreateProductRequest) (*domusecase.ProductSummary, error) {
- if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID); err != nil {
- return nil, err
- }
- label := strings.TrimSpace(req.Label)
- contextRaw := strings.TrimSpace(req.ProductContext)
- if label == "" {
- return nil, app.For(code.Brand).InputMissingRequired("product label is required")
- }
- if !placement.HasProductContext(contextRaw) {
- return nil, app.For(code.Brand).InputMissingRequired("product context is required")
- }
- product := entity.Product{
- ID: uuid.NewString(),
- Label: label,
- ProductContext: contextRaw,
- PlacementURL: strings.TrimSpace(req.PlacementURL),
- MatchTags: normalizeMatchTags(req.MatchTags),
- }
- created, err := u.repo.PushProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, product)
- if err != nil {
- return nil, err
- }
- summary := toProductSummary(*created)
- return &summary, nil
-}
-
-func (u *brandUseCase) UpdateProduct(ctx context.Context, req domusecase.UpdateProductRequest) (*domusecase.ProductSummary, error) {
- brand, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID)
- if err != nil {
- return nil, err
- }
- if placement.FindProduct(*brand, req.ProductID) == nil {
- return nil, app.For(code.Brand).ResNotFound("product not found")
- }
- patch := map[string]interface{}{}
- if req.Label != nil {
- label := strings.TrimSpace(*req.Label)
- if label == "" {
- return nil, app.For(code.Brand).InputMissingRequired("product label is required")
- }
- patch["products.$.label"] = label
- }
- if req.ProductContext != nil {
- contextRaw := strings.TrimSpace(*req.ProductContext)
- if !placement.HasProductContext(contextRaw) {
- return nil, app.For(code.Brand).InputMissingRequired("product context is required")
- }
- patch["products.$.product_context"] = contextRaw
- }
- if req.MatchTagsSet {
- patch["products.$.match_tags"] = normalizeMatchTags(req.MatchTags)
- }
- if req.PlacementURLSet {
- patch["products.$.placement_url"] = strings.TrimSpace(ptrString(req.PlacementURL))
- }
- if len(patch) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("product patch is empty")
- }
- updated, err := u.repo.UpdateProduct(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ProductID, patch)
- if err != nil {
- return nil, err
- }
- if brand.ProductID == req.ProductID {
- _ = u.syncProductSnapshot(ctx, req.TenantID, req.OwnerUID, req.BrandID, brand.ProductID)
- }
- summary := toProductSummary(*updated)
- return &summary, nil
-}
-
-func (u *brandUseCase) DeleteProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error {
- brand, err := u.assertOwned(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return err
- }
- if placement.FindProduct(*brand, productID) == nil {
- return app.For(code.Brand).ResNotFound("product not found")
- }
- if err := u.repo.PullProduct(ctx, tenantID, ownerUID, brandID, productID); err != nil {
- return err
- }
- if brand.ProductID == productID {
- _, _ = u.repo.Update(ctx, tenantID, ownerUID, brandID, map[string]interface{}{
- "product_id": "",
- "product_context": "",
- })
- }
- return nil
-}
-
-func (u *brandUseCase) syncProductSnapshot(ctx context.Context, tenantID, ownerUID, brandID, productID string) error {
- brand, err := u.assertOwned(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return err
- }
- snapshot := placement.ResolveBrandProductContext(*brand, productID, "")
- patch := map[string]interface{}{
- "product_context": snapshot,
- }
- if id := strings.TrimSpace(productID); id != "" {
- patch["product_id"] = id
- } else {
- patch["product_id"] = ""
- }
- _, err = u.repo.Update(ctx, tenantID, ownerUID, brandID, patch)
- return err
-}
-
-func toProductSummary(item entity.Product) domusecase.ProductSummary {
- return domusecase.ProductSummary{
- ID: item.ID,
- Label: item.Label,
- ProductContext: item.ProductContext,
- PlacementURL: item.PlacementURL,
- MatchTags: append([]string(nil), item.MatchTags...),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func ptrString(v *string) string {
- if v == nil {
- return ""
- }
- return *v
-}
-
-func normalizeMatchTags(tags []string) []string {
- if len(tags) == 0 {
- return nil
- }
- seen := map[string]struct{}{}
- out := make([]string, 0, len(tags))
- for _, raw := range tags {
- tag := strings.TrimSpace(raw)
- if tag == "" {
- continue
- }
- key := strings.ToLower(tag)
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, tag)
- }
- return out
-}
diff --git a/old/backend/internal/model/brand/usecase/products_placement_url_test.go b/old/backend/internal/model/brand/usecase/products_placement_url_test.go
deleted file mode 100644
index a3f4155..0000000
--- a/old/backend/internal/model/brand/usecase/products_placement_url_test.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/brand/domain/entity"
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
-)
-
-type memoryBrandRepo struct {
- brand *entity.Brand
-}
-
-func (m *memoryBrandRepo) Create(ctx context.Context, brand *entity.Brand) (*entity.Brand, error) {
- m.brand = brand
- return brand, nil
-}
-
-func (m *memoryBrandRepo) FindByID(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error) {
- return m.brand, nil
-}
-
-func (m *memoryBrandRepo) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Brand, error) {
- return nil, nil
-}
-
-func (m *memoryBrandRepo) Update(ctx context.Context, tenantID, ownerUID, brandID string, patch map[string]interface{}) (*entity.Brand, error) {
- return m.brand, nil
-}
-
-func (m *memoryBrandRepo) SoftDelete(ctx context.Context, tenantID, ownerUID, brandID string) error {
- return nil
-}
-
-func (m *memoryBrandRepo) PushProduct(ctx context.Context, tenantID, ownerUID, brandID string, product entity.Product) (*entity.Product, error) {
- m.brand.Products = append(m.brand.Products, product)
- return &product, nil
-}
-
-func (m *memoryBrandRepo) UpdateProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string, patch map[string]interface{}) (*entity.Product, error) {
- for i := range m.brand.Products {
- if m.brand.Products[i].ID != productID {
- continue
- }
- if v, ok := patch["products.$.label"].(string); ok {
- m.brand.Products[i].Label = v
- }
- if v, ok := patch["products.$.product_context"].(string); ok {
- m.brand.Products[i].ProductContext = v
- }
- if v, ok := patch["products.$.placement_url"].(string); ok {
- m.brand.Products[i].PlacementURL = v
- }
- item := m.brand.Products[i]
- return &item, nil
- }
- return nil, nil
-}
-
-func (m *memoryBrandRepo) PullProduct(ctx context.Context, tenantID, ownerUID, brandID, productID string) error {
- return nil
-}
-
-func (m *memoryBrandRepo) EnsureIndexes(ctx context.Context) error {
- return nil
-}
-
-func TestCreateAndUpdateProductPlacementURL(t *testing.T) {
- repo := &memoryBrandRepo{
- brand: &entity.Brand{
- ID: "brand-1",
- TenantID: "tenant-1",
- OwnerUID: "user-1",
- Status: entity.StatusOpen,
- },
- }
- uc := NewUseCase(repo)
- ctx := context.Background()
-
- created, err := uc.CreateProduct(ctx, domusecase.CreateProductRequest{
- TenantID: "tenant-1",
- OwnerUID: "user-1",
- BrandID: "brand-1",
- Label: "主力商品",
- ProductContext: "賣點描述",
- PlacementURL: "https://example.com/product",
- })
- if err != nil {
- t.Fatal(err)
- }
- if created.PlacementURL != "https://example.com/product" {
- t.Fatalf("create placement_url = %q", created.PlacementURL)
- }
-
- label := "主力商品"
- contextRaw := "賣點描述"
- placementURL := "https://example.com/updated"
- updated, err := uc.UpdateProduct(ctx, domusecase.UpdateProductRequest{
- TenantID: "tenant-1",
- OwnerUID: "user-1",
- BrandID: "brand-1",
- ProductID: created.ID,
- Label: &label,
- ProductContext: &contextRaw,
- PlacementURL: &placementURL,
- PlacementURLSet: true,
- })
- if err != nil {
- t.Fatal(err)
- }
- if updated.PlacementURL != placementURL {
- t.Fatalf("update placement_url = %q, want %q", updated.PlacementURL, placementURL)
- }
-}
diff --git a/old/backend/internal/model/brand/usecase/usecase.go b/old/backend/internal/model/brand/usecase/usecase.go
deleted file mode 100644
index 1b0edba..0000000
--- a/old/backend/internal/model/brand/usecase/usecase.go
+++ /dev/null
@@ -1,244 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/model/brand/domain/entity"
- domrepo "haixun-backend/internal/model/brand/domain/repository"
- domusecase "haixun-backend/internal/model/brand/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type brandUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &brandUseCase{repo: repo}
-}
-
-func (u *brandUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- list := make([]domusecase.BrandSummary, 0, len(items))
- for _, item := range items {
- list = append(list, toSummary(item))
- }
- return &domusecase.ListResult{List: list}, nil
-}
-
-func (u *brandUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.BrandSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- displayName := strings.TrimSpace(req.DisplayName)
- if displayName == "" {
- existing, err := u.repo.ListByOwner(ctx, req.TenantID, req.OwnerUID)
- if err != nil {
- return nil, err
- }
- displayName = "品牌 " + itoa(len(existing)+1)
- }
- productBrief := strings.TrimSpace(req.ProductBrief)
- if productBrief == "" && strings.TrimSpace(req.ProductContext) != "" {
- productBrief = strings.TrimSpace(req.ProductContext)
- }
- brand := &entity.Brand{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- DisplayName: displayName,
- SeedQuery: strings.TrimSpace(req.SeedQuery),
- Brief: strings.TrimSpace(req.Brief),
- ProductBrief: productBrief,
- ProductContext: strings.TrimSpace(req.ProductContext),
- TargetAudience: strings.TrimSpace(req.TargetAudience),
- Goals: strings.TrimSpace(req.Goals),
- Status: entity.StatusOpen,
- }
- if req.ResearchMap != nil {
- brand.ResearchMap = *req.ResearchMap
- }
- item, err := u.repo.Create(ctx, brand)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *brandUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.BrandSummary, error) {
- item, err := u.assertOwned(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *brandUseCase) Delete(ctx context.Context, tenantID, ownerUID, brandID string) error {
- if _, err := u.assertOwned(ctx, tenantID, ownerUID, brandID); err != nil {
- return err
- }
- return u.repo.SoftDelete(ctx, tenantID, ownerUID, brandID)
-}
-
-func (u *brandUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.BrandSummary, error) {
- brand, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.BrandID)
- if err != nil {
- return nil, err
- }
- patch := patchToMap(req.Patch)
- if len(patch) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("brand patch is empty")
- }
- if req.Patch.ProductID != nil {
- snapshot := placement.ResolveBrandProductContext(*brand, strings.TrimSpace(*req.Patch.ProductID), "")
- patch["product_context"] = snapshot
- }
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.BrandID, patch)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *brandUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Brand, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if strings.TrimSpace(brandID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("brand id is required")
- }
- return u.repo.FindByID(ctx, tenantID, ownerUID, brandID)
-}
-
-func requireActor(tenantID, ownerUID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required")
- }
- return nil
-}
-
-func toSummary(item *entity.Brand) domusecase.BrandSummary {
- if item == nil {
- return domusecase.BrandSummary{}
- }
- products := make([]domusecase.ProductSummary, 0, len(item.Products))
- for _, product := range item.Products {
- products = append(products, toProductSummary(product))
- }
- return domusecase.BrandSummary{
- ID: item.ID,
- DisplayName: item.DisplayName,
- TopicName: item.TopicName,
- SeedQuery: item.SeedQuery,
- Brief: item.Brief,
- ProductBrief: item.ProductBrief,
- ProductContext: item.ProductContext,
- ProductID: item.ProductID,
- Products: products,
- TargetAudience: item.TargetAudience,
- Goals: item.Goals,
- ResearchMap: item.ResearchMap,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func patchToMap(patch domusecase.BrandPatch) map[string]interface{} {
- out := map[string]interface{}{}
- if patch.DisplayName != nil {
- out["display_name"] = strings.TrimSpace(*patch.DisplayName)
- }
- if patch.TopicName != nil {
- out["topic_name"] = strings.TrimSpace(*patch.TopicName)
- }
- if patch.SeedQuery != nil {
- out["seed_query"] = strings.TrimSpace(*patch.SeedQuery)
- }
- if patch.Brief != nil {
- out["brief"] = strings.TrimSpace(*patch.Brief)
- }
- if patch.ProductBrief != nil {
- out["product_brief"] = strings.TrimSpace(*patch.ProductBrief)
- }
- if patch.ProductContext != nil {
- out["product_context"] = strings.TrimSpace(*patch.ProductContext)
- }
- if patch.ProductID != nil {
- out["product_id"] = strings.TrimSpace(*patch.ProductID)
- }
- if patch.TargetAudience != nil {
- out["target_audience"] = strings.TrimSpace(*patch.TargetAudience)
- }
- if patch.Goals != nil {
- out["goals"] = strings.TrimSpace(*patch.Goals)
- }
- if patch.AudienceSummary != nil {
- out["research_map.audience_summary"] = strings.TrimSpace(*patch.AudienceSummary)
- }
- if patch.ContentGoal != nil {
- out["research_map.content_goal"] = strings.TrimSpace(*patch.ContentGoal)
- }
- if patch.QuestionsSet {
- out["research_map.questions"] = cleanStringList(patch.Questions)
- }
- if patch.PillarsSet {
- out["research_map.pillars"] = cleanStringList(patch.Pillars)
- }
- if patch.ExclusionsSet {
- out["research_map.exclusions"] = cleanStringList(patch.Exclusions)
- }
- if patch.ResearchMap != nil {
- out["research_map"] = *patch.ResearchMap
- }
- if patch.PatrolKeywordsSet {
- out["research_map.patrol_keywords"] = libkg.NormalizePatrolKeywordList(patch.PatrolKeywords)
- }
- return out
-}
-
-func cleanStringList(items []string) []string {
- out := make([]string, 0, len(items))
- seen := make(map[string]struct{}, len(items))
- for _, item := range items {
- trimmed := strings.TrimSpace(item)
- if trimmed == "" {
- continue
- }
- if _, ok := seen[trimmed]; ok {
- continue
- }
- seen[trimmed] = struct{}{}
- out = append(out, trimmed)
- }
- return out
-}
-
-func itoa(n int) string {
- if n <= 0 {
- return "1"
- }
- buf := make([]byte, 0, 12)
- for n > 0 {
- buf = append(buf, byte('0'+n%10))
- n /= 10
- }
- for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
- buf[i], buf[j] = buf[j], buf[i]
- }
- return string(buf)
-}
diff --git a/old/backend/internal/model/content_formula/domain/entity/formula.go b/old/backend/internal/model/content_formula/domain/entity/formula.go
deleted file mode 100644
index 0eff018..0000000
--- a/old/backend/internal/model/content_formula/domain/entity/formula.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package entity
-
-const CollectionName = "content_formulas"
-
-const (
- SourcePaste = "paste"
- SourceOwnPost = "own_post"
- SourceScanPost = "scan_post"
- SourceKeywordSearch = "keyword_search"
-)
-
-type SourceMetrics struct {
- LikeCount int `bson:"like_count,omitempty"`
- ReplyCount int `bson:"reply_count,omitempty"`
- Views int `bson:"views,omitempty"`
- RepostCount int `bson:"repost_count,omitempty"`
- QuoteCount int `bson:"quote_count,omitempty"`
- Shares int `bson:"shares,omitempty"`
-}
-
-type ContentFormula struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- Label string `bson:"label"`
- SourceType string `bson:"source_type"`
- SourceRef string `bson:"source_ref,omitempty"`
- SourcePostText string `bson:"source_post_text,omitempty"`
- SourceAuthor string `bson:"source_author,omitempty"`
- SourcePermalink string `bson:"source_permalink,omitempty"`
- SourceMetrics *SourceMetrics `bson:"source_metrics,omitempty"`
- Summary string `bson:"summary"`
- Wins []string `bson:"wins,omitempty"`
- Improvements []string `bson:"improvements,omitempty"`
- Formula string `bson:"formula"`
- PostTemplate string `bson:"post_template"`
- HookPattern string `bson:"hook_pattern"`
- Structure string `bson:"structure"`
- ReplicationTips []string `bson:"replication_tips,omitempty"`
- Avoid []string `bson:"avoid,omitempty"`
- Tags []string `bson:"tags,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/content_formula/domain/repository/repository.go b/old/backend/internal/model/content_formula/domain/repository/repository.go
deleted file mode 100644
index 3ad8962..0000000
--- a/old/backend/internal/model/content_formula/domain/repository/repository.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/content_formula/domain/entity"
-)
-
-type ListFilter struct {
- TenantID string
- OwnerUID string
- AccountID string
- SourceType string
- Tag string
- Page int
- PageSize int
-}
-
-type ListResult struct {
- Items []entity.ContentFormula
- Total int64
-}
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, formula *entity.ContentFormula) error
- Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error)
- Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error)
- Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error
- List(ctx context.Context, filter ListFilter) (*ListResult, error)
-}
diff --git a/old/backend/internal/model/content_formula/domain/usecase/usecase.go b/old/backend/internal/model/content_formula/domain/usecase/usecase.go
deleted file mode 100644
index 3969de1..0000000
--- a/old/backend/internal/model/content_formula/domain/usecase/usecase.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/content_formula/domain/entity"
-)
-
-type FormulaSummary struct {
- ID string
- AccountID string
- Label string
- SourceType string
- SourceRef string
- SourcePostText string
- SourceAuthor string
- SourcePermalink string
- SourceMetrics *entity.SourceMetrics
- Summary string
- Wins []string
- Improvements []string
- Formula string
- PostTemplate string
- HookPattern string
- Structure string
- ReplicationTips []string
- Avoid []string
- Tags []string
- CreateAt int64
- UpdateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Label string
- SourceType string
- SourceRef string
- SourcePostText string
- SourceAuthor string
- SourcePermalink string
- SourceMetrics *entity.SourceMetrics
- Summary string
- Wins []string
- Improvements []string
- Formula string
- PostTemplate string
- HookPattern string
- Structure string
- ReplicationTips []string
- Avoid []string
- Tags []string
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- FormulaID string
- Label *string
- Formula *string
- PostTemplate *string
- HookPattern *string
- Structure *string
- Tags []string
-}
-
-type ListRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- SourceType string
- Tag string
- Page int
- PageSize int
-}
-
-type ListResult struct {
- Items []FormulaSummary
- Total int64
-}
-
-type UseCase interface {
- Create(ctx context.Context, req CreateRequest) (*FormulaSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*FormulaSummary, error)
- Update(ctx context.Context, req UpdateRequest) (*FormulaSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error
- List(ctx context.Context, req ListRequest) (*ListResult, error)
-}
diff --git a/old/backend/internal/model/content_formula/repository/mongo.go b/old/backend/internal/model/content_formula/repository/mongo.go
deleted file mode 100644
index b7336e3..0000000
--- a/old/backend/internal/model/content_formula/repository/mongo.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/content_formula/domain/entity"
- domrepo "haixun-backend/internal/model/content_formula/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "update_at", Value: -1},
- },
- },
- })
- return err
-}
-
-func actorFilter(tenantID, ownerUID, accountID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "account_id": strings.TrimSpace(accountID),
- }
-}
-
-func normalizePage(page, pageSize int) (int, int) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 20
- }
- if pageSize > 100 {
- pageSize = 100
- }
- return page, pageSize
-}
-
-func (r *mongoRepository) Create(ctx context.Context, formula *entity.ContentFormula) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if formula == nil {
- return app.For(code.ThreadsAccount).InputMissingRequired("formula is required")
- }
- _, err := r.collection.InsertOne(ctx, formula)
- return err
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*entity.ContentFormula, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- formulaID = strings.TrimSpace(formulaID)
- if formulaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula_id is required")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["_id"] = formulaID
- var out entity.ContentFormula
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, accountID, formulaID string, patch map[string]interface{}) (*entity.ContentFormula, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("patch is required")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["_id"] = strings.TrimSpace(formulaID)
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.ContentFormula
- err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("content formula not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["_id"] = strings.TrimSpace(formulaID)
- res, err := r.collection.DeleteOne(ctx, filter)
- if err != nil {
- return err
- }
- if res.DeletedCount == 0 {
- return app.For(code.ThreadsAccount).ResNotFound("content formula not found")
- }
- return nil
-}
-
-func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter) (*domrepo.ListResult, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- page, pageSize := normalizePage(filter.Page, filter.PageSize)
- mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID)
- if sourceType := strings.TrimSpace(filter.SourceType); sourceType != "" {
- mongoFilter["source_type"] = sourceType
- }
- if tag := strings.TrimSpace(filter.Tag); tag != "" {
- mongoFilter["tags"] = tag
- }
- total, err := r.collection.CountDocuments(ctx, mongoFilter)
- if err != nil {
- return nil, err
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "update_at", Value: -1}}).
- SetSkip(int64((page - 1) * pageSize)).
- SetLimit(int64(pageSize))
- cur, err := r.collection.Find(ctx, mongoFilter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var items []entity.ContentFormula
- if err := cur.All(ctx, &items); err != nil {
- return nil, err
- }
- return &domrepo.ListResult{Items: items, Total: total}, nil
-}
diff --git a/old/backend/internal/model/content_formula/usecase/usecase.go b/old/backend/internal/model/content_formula/usecase/usecase.go
deleted file mode 100644
index 9cd4eb9..0000000
--- a/old/backend/internal/model/content_formula/usecase/usecase.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/content_formula/domain/entity"
- domrepo "haixun-backend/internal/model/content_formula/domain/repository"
- domusecase "haixun-backend/internal/model/content_formula/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type useCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &useCase{repo: repo}
-}
-
-func (u *useCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.FormulaSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil {
- return nil, err
- }
- label := strings.TrimSpace(req.Label)
- if label == "" {
- label = "未命名公式"
- }
- sourceType := strings.TrimSpace(req.SourceType)
- if sourceType == "" {
- sourceType = entity.SourcePaste
- }
- formulaText := strings.TrimSpace(req.Formula)
- if formulaText == "" && strings.TrimSpace(req.PostTemplate) == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("formula or post_template is required")
- }
- now := clock.NowUnixNano()
- item := &entity.ContentFormula{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- AccountID: req.AccountID,
- Label: label,
- SourceType: sourceType,
- SourceRef: strings.TrimSpace(req.SourceRef),
- SourcePostText: strings.TrimSpace(req.SourcePostText),
- SourceAuthor: strings.TrimSpace(req.SourceAuthor),
- SourcePermalink: strings.TrimSpace(req.SourcePermalink),
- SourceMetrics: req.SourceMetrics,
- Summary: strings.TrimSpace(req.Summary),
- Wins: req.Wins,
- Improvements: req.Improvements,
- Formula: formulaText,
- PostTemplate: strings.TrimSpace(req.PostTemplate),
- HookPattern: strings.TrimSpace(req.HookPattern),
- Structure: strings.TrimSpace(req.Structure),
- ReplicationTips: req.ReplicationTips,
- Avoid: req.Avoid,
- Tags: req.Tags,
- CreateAt: now,
- UpdateAt: now,
- }
- if err := u.repo.Create(ctx, item); err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) (*domusecase.FormulaSummary, error) {
- if err := requireActor(tenantID, ownerUID, accountID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, formulaID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *useCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.FormulaSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil {
- return nil, err
- }
- patch := map[string]interface{}{"update_at": clock.NowUnixNano()}
- if req.Label != nil {
- patch["label"] = strings.TrimSpace(*req.Label)
- }
- if req.Formula != nil {
- patch["formula"] = strings.TrimSpace(*req.Formula)
- }
- if req.PostTemplate != nil {
- patch["post_template"] = strings.TrimSpace(*req.PostTemplate)
- }
- if req.HookPattern != nil {
- patch["hook_pattern"] = strings.TrimSpace(*req.HookPattern)
- }
- if req.Structure != nil {
- patch["structure"] = strings.TrimSpace(*req.Structure)
- }
- if req.Tags != nil {
- patch["tags"] = req.Tags
- }
- if len(patch) == 1 {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("no fields to update")
- }
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.FormulaID, patch)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *useCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, formulaID string) error {
- if err := requireActor(tenantID, ownerUID, accountID); err != nil {
- return err
- }
- return u.repo.Delete(ctx, tenantID, ownerUID, accountID, formulaID)
-}
-
-func (u *useCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.AccountID); err != nil {
- return nil, err
- }
- result, err := u.repo.List(ctx, domrepo.ListFilter{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- AccountID: req.AccountID,
- SourceType: req.SourceType,
- Tag: req.Tag,
- Page: req.Page,
- PageSize: req.PageSize,
- })
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.FormulaSummary, 0, len(result.Items))
- for _, item := range result.Items {
- out = append(out, toSummary(item))
- }
- return &domusecase.ListResult{Items: out, Total: result.Total}, nil
-}
-
-func requireActor(tenantID, ownerUID, accountID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(accountID) == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- return nil
-}
-
-func toSummary(item entity.ContentFormula) domusecase.FormulaSummary {
- return domusecase.FormulaSummary{
- ID: item.ID,
- AccountID: item.AccountID,
- Label: item.Label,
- SourceType: item.SourceType,
- SourceRef: item.SourceRef,
- SourcePostText: item.SourcePostText,
- SourceAuthor: item.SourceAuthor,
- SourcePermalink: item.SourcePermalink,
- SourceMetrics: item.SourceMetrics,
- Summary: item.Summary,
- Wins: item.Wins,
- Improvements: item.Improvements,
- Formula: item.Formula,
- PostTemplate: item.PostTemplate,
- HookPattern: item.HookPattern,
- Structure: item.Structure,
- ReplicationTips: item.ReplicationTips,
- Avoid: item.Avoid,
- Tags: item.Tags,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
diff --git a/old/backend/internal/model/content_matrix/domain/entity/matrix.go b/old/backend/internal/model/content_matrix/domain/entity/matrix.go
deleted file mode 100644
index 5b0095c..0000000
--- a/old/backend/internal/model/content_matrix/domain/entity/matrix.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package entity
-
-const CollectionName = "content_matrices"
-
-type Row struct {
- SortOrder int `bson:"sort_order"`
- SearchTag string `bson:"search_tag"`
- Angle string `bson:"angle"`
- Hook string `bson:"hook"`
- Text string `bson:"text"`
- ReferenceNotes string `bson:"reference_notes"`
- SourcePermalinks []string `bson:"source_permalinks"`
- Rationale string `bson:"rationale"`
-}
-
-type ContentMatrix struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id"`
- LegacyPersonaID string `bson:"persona_id,omitempty"`
- Rows []Row `bson:"rows"`
- GeneratedAt int64 `bson:"generated_at"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/content_matrix/domain/repository/repository.go b/old/backend/internal/model/content_matrix/domain/repository/repository.go
deleted file mode 100644
index b8e58f6..0000000
--- a/old/backend/internal/model/content_matrix/domain/repository/repository.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/content_matrix/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- UpsertByBrand(ctx context.Context, matrix *entity.ContentMatrix) (*entity.ContentMatrix, error)
- GetByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.ContentMatrix, error)
-}
diff --git a/old/backend/internal/model/content_matrix/domain/usecase/usecase.go b/old/backend/internal/model/content_matrix/domain/usecase/usecase.go
deleted file mode 100644
index ef0ee8a..0000000
--- a/old/backend/internal/model/content_matrix/domain/usecase/usecase.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package usecase
-
-import (
- "context"
-)
-
-type Row struct {
- SortOrder int
- SearchTag string
- Angle string
- Hook string
- Text string
- ReferenceNotes string
- SourcePermalinks []string
- Rationale string
-}
-
-type MatrixSummary struct {
- ID string
- BrandID string
- Rows []Row
- GeneratedAt int64
- CreateAt int64
- UpdateAt int64
-}
-
-type UpsertRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- Rows []Row
- GeneratedAt int64
-}
-
-type UseCase interface {
- Get(ctx context.Context, tenantID, ownerUID, brandID string) (*MatrixSummary, error)
- Upsert(ctx context.Context, req UpsertRequest) (*MatrixSummary, error)
-}
diff --git a/old/backend/internal/model/content_matrix/repository/mongo.go b/old/backend/internal/model/content_matrix/repository/mongo.go
deleted file mode 100644
index f1fe1de..0000000
--- a/old/backend/internal/model/content_matrix/repository/mongo.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/model/content_matrix/domain/entity"
- domrepo "haixun-backend/internal/model/content_matrix/domain/repository"
-
- "github.com/google/uuid"
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}},
- Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}})},
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}},
- Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})},
- })
-}
-
-func brandOwnerFilter(tenantID, ownerUID, brandID string) bson.M {
- filter := bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- }
- for k, v := range libmongo.BrandScopeFilter(brandID) {
- filter[k] = v
- }
- return filter
-}
-
-func (r *mongoRepository) UpsertByBrand(ctx context.Context, matrix *entity.ContentMatrix) (*entity.ContentMatrix, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- matrix.UpdateAt = now
- if matrix.CreateAt == 0 {
- matrix.CreateAt = now
- }
- if strings.TrimSpace(matrix.ID) == "" {
- matrix.ID = uuid.NewString()
- }
- filter := brandOwnerFilter(matrix.TenantID, matrix.OwnerUID, matrix.BrandID)
- update := bson.M{
- "$set": bson.M{
- "rows": matrix.Rows,
- "generated_at": matrix.GeneratedAt,
- "update_at": matrix.UpdateAt,
- "brand_id": matrix.BrandID,
- },
- "$setOnInsert": bson.M{
- "_id": matrix.ID,
- "tenant_id": matrix.TenantID,
- "owner_uid": matrix.OwnerUID,
- "create_at": matrix.CreateAt,
- },
- }
- opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
- var out entity.ContentMatrix
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) GetByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.ContentMatrix, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- var out entity.ContentMatrix
- err := r.collection.FindOne(ctx, brandOwnerFilter(tenantID, ownerUID, brandID)).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/content_matrix/usecase/usecase.go b/old/backend/internal/model/content_matrix/usecase/usecase.go
deleted file mode 100644
index e6f9815..0000000
--- a/old/backend/internal/model/content_matrix/usecase/usecase.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/model/content_matrix/domain/entity"
- domrepo "haixun-backend/internal/model/content_matrix/domain/repository"
- domusecase "haixun-backend/internal/model/content_matrix/domain/usecase"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libmongo "haixun-backend/internal/library/mongo"
-)
-
-type contentMatrixUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &contentMatrixUseCase{repo: repo}
-}
-
-func (u *contentMatrixUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.MatrixSummary, error) {
- if err := requireActor(tenantID, ownerUID, brandID); err != nil {
- return nil, err
- }
- record, err := u.repo.GetByBrand(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return nil, err
- }
- if record == nil {
- return &domusecase.MatrixSummary{BrandID: brandID, Rows: []domusecase.Row{}}, nil
- }
- return toSummary(record), nil
-}
-
-func (u *contentMatrixUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.MatrixSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil {
- return nil, err
- }
- rows := make([]entity.Row, 0, len(req.Rows))
- for _, row := range req.Rows {
- rows = append(rows, entity.Row{
- SortOrder: row.SortOrder,
- SearchTag: row.SearchTag,
- Angle: row.Angle,
- Hook: row.Hook,
- Text: row.Text,
- ReferenceNotes: row.ReferenceNotes,
- SourcePermalinks: row.SourcePermalinks,
- Rationale: row.Rationale,
- })
- }
- record := &entity.ContentMatrix{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- BrandID: req.BrandID,
- Rows: rows,
- GeneratedAt: req.GeneratedAt,
- }
- saved, err := u.repo.UpsertByBrand(ctx, record)
- if err != nil {
- return nil, err
- }
- return toSummary(saved), nil
-}
-
-func requireActor(tenantID, ownerUID, brandID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required")
- }
- if strings.TrimSpace(brandID) == "" {
- return app.For(code.Brand).InputMissingRequired("brand id is required")
- }
- return nil
-}
-
-func toSummary(record *entity.ContentMatrix) *domusecase.MatrixSummary {
- if record == nil {
- return nil
- }
- rows := make([]domusecase.Row, 0, len(record.Rows))
- for _, row := range record.Rows {
- rows = append(rows, domusecase.Row{
- SortOrder: row.SortOrder,
- SearchTag: row.SearchTag,
- Angle: row.Angle,
- Hook: row.Hook,
- Text: row.Text,
- ReferenceNotes: row.ReferenceNotes,
- SourcePermalinks: row.SourcePermalinks,
- Rationale: row.Rationale,
- })
- }
- return &domusecase.MatrixSummary{
- ID: record.ID,
- BrandID: libmongo.ResolveBrandID(record.BrandID, record.LegacyPersonaID),
- Rows: rows,
- GeneratedAt: record.GeneratedAt,
- CreateAt: record.CreateAt,
- UpdateAt: record.UpdateAt,
- }
-}
diff --git a/old/backend/internal/model/content_ops/domain/entity/entity.go b/old/backend/internal/model/content_ops/domain/entity/entity.go
deleted file mode 100644
index 5bafaf7..0000000
--- a/old/backend/internal/model/content_ops/domain/entity/entity.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package entity
-
-const (
- TopicCandidateCollectionName = "topic_candidates"
- ContentPlanCollectionName = "content_plans"
- FeedbackEventCollectionName = "feedback_events"
- KnowledgeSourceCollectionName = "knowledge_sources"
- KnowledgeChunkCollectionName = "knowledge_chunks"
- FormulaPoolCollectionName = "formula_pools"
-)
-
-type TopicCandidate struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- Name string `bson:"name"`
- Source string `bson:"source,omitempty"`
- Category string `bson:"category,omitempty"`
- SeedQuery string `bson:"seed_query,omitempty"`
- TrendReason string `bson:"trend_reason,omitempty"`
- TrendKeywords []string `bson:"trend_keywords,omitempty"`
- HeatScore int `bson:"heat_score,omitempty"`
- FitScore int `bson:"fit_score,omitempty"`
- InteractionScore int `bson:"interaction_score,omitempty"`
- ExtendScore int `bson:"extend_score,omitempty"`
- RiskScore int `bson:"risk_score,omitempty"`
- FinalScore float64 `bson:"final_score,omitempty"`
- RecommendedMissions []string `bson:"recommended_missions,omitempty"`
- Status string `bson:"status,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-type KnowledgeSource struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- SourceType string `bson:"source_type"`
- Title string `bson:"title,omitempty"`
- Filename string `bson:"filename,omitempty"`
- RawContent string `bson:"raw_content,omitempty"`
- ParsedStatus string `bson:"parsed_status,omitempty"`
- ChunkCount int `bson:"chunk_count,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
-
-type KnowledgeChunk struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- SourceID string `bson:"source_id"`
- Content string `bson:"content"`
- Topics []string `bson:"topics,omitempty"`
- StyleTags []string `bson:"style_tags,omitempty"`
- RiskLevel string `bson:"risk_level,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
-
-type FormulaPool struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- Type string `bson:"type"`
- Name string `bson:"name"`
- Pattern string `bson:"pattern,omitempty"`
- UseCases []string `bson:"use_cases,omitempty"`
- Avoid []string `bson:"avoid,omitempty"`
- Weight int `bson:"weight,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-type ContentPlan struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- TopicCandidateID string `bson:"topic_candidate_id,omitempty"`
- Topic string `bson:"topic"`
- Mission string `bson:"mission"`
- TargetAudience string `bson:"target_audience,omitempty"`
- Angle string `bson:"angle,omitempty"`
- OpeningType string `bson:"opening_type,omitempty"`
- BodyType string `bson:"body_type,omitempty"`
- Emotion string `bson:"emotion,omitempty"`
- EndingType string `bson:"ending_type,omitempty"`
- CtaType string `bson:"cta_type,omitempty"`
- RiskLevel string `bson:"risk_level,omitempty"`
- RequiresHumanReview bool `bson:"requires_human_review,omitempty"`
- Avoid []string `bson:"avoid,omitempty"`
- SelectedKnowledge []string `bson:"selected_knowledge,omitempty"`
- Status string `bson:"status,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-type FeedbackEvent struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- ContentPlanID string `bson:"content_plan_id,omitempty"`
- DraftID string `bson:"draft_id,omitempty"`
- Decision string `bson:"decision"`
- Note string `bson:"note,omitempty"`
- Snapshot string `bson:"snapshot,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
diff --git a/old/backend/internal/model/content_ops/domain/repository/repository.go b/old/backend/internal/model/content_ops/domain/repository/repository.go
deleted file mode 100644
index 768f9b8..0000000
--- a/old/backend/internal/model/content_ops/domain/repository/repository.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/content_ops/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error
- ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error)
- CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error
- UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error)
- GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error)
- ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error)
- CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error
- ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error)
- CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error
- ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error)
- ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error)
- CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error
- ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error)
-}
diff --git a/old/backend/internal/model/content_ops/domain/usecase/usecase.go b/old/backend/internal/model/content_ops/domain/usecase/usecase.go
deleted file mode 100644
index a715fa4..0000000
--- a/old/backend/internal/model/content_ops/domain/usecase/usecase.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package usecase
-
-import "context"
-
-type TopicCandidateSummary struct {
- ID string
- PersonaID string
- Name string
- Source string
- Category string
- SeedQuery string
- TrendReason string
- TrendKeywords []string
- HeatScore int
- FitScore int
- InteractionScore int
- ExtendScore int
- RiskScore int
- FinalScore float64
- RecommendedMissions []string
- Status string
- CreateAt int64
- UpdateAt int64
-}
-
-type ContentPlanSummary struct {
- ID string
- PersonaID string
- TopicCandidateID string
- Topic string
- Mission string
- TargetAudience string
- Angle string
- OpeningType string
- BodyType string
- Emotion string
- EndingType string
- CtaType string
- RiskLevel string
- RequiresHumanReview bool
- Avoid []string
- SelectedKnowledge []string
- Status string
- CreateAt int64
- UpdateAt int64
-}
-
-type FeedbackEventSummary struct {
- ID string
- PersonaID string
- ContentPlanID string
- DraftID string
- Decision string
- Note string
- Snapshot string
- CreateAt int64
-}
-
-type KnowledgeSourceSummary struct {
- ID, PersonaID, SourceType, Title, Filename, ParsedStatus string
- ChunkCount int
- CreateAt int64
-}
-type KnowledgeChunkSummary struct {
- ID, PersonaID, SourceID, Content, RiskLevel string
- Topics, StyleTags []string
- CreateAt int64
-}
-type FormulaPoolSummary struct {
- ID, PersonaID, Type, Name, Pattern string
- UseCases, Avoid []string
- Weight int
- CreateAt, UpdateAt int64
-}
-
-type KnowledgeSourceInput struct{ TenantID, OwnerUID, PersonaID, SourceType, Title, Filename, Content, ContentBase64 string }
-type FormulaPoolInput struct {
- TenantID, OwnerUID, PersonaID, Type, Name, Pattern string
- UseCases, Avoid []string
- Weight int
-}
-
-type TopicCandidateInput struct {
- TenantID string
- OwnerUID string
- PersonaID string
- Name string
- Source string
- Category string
- SeedQuery string
- TrendReason string
- TrendKeywords []string
- HeatScore int
- FitScore int
- InteractionScore int
- ExtendScore int
- RiskScore int
- RecommendedMissions []string
-}
-
-type ContentPlanInput struct {
- TenantID string
- OwnerUID string
- PersonaID string
- TopicCandidateID string
- Topic string
- Mission string
- TargetAudience string
- Angle string
- OpeningType string
- BodyType string
- Emotion string
- EndingType string
- CtaType string
- RiskLevel string
- RequiresHumanReview bool
- Avoid []string
- SelectedKnowledge []string
-}
-
-type ContentPlanPatch struct {
- Topic *string
- Mission *string
- TargetAudience *string
- Angle *string
- OpeningType *string
- BodyType *string
- Emotion *string
- EndingType *string
- CtaType *string
- RiskLevel *string
- RequiresHumanReview *bool
- Avoid []string
- SelectedKnowledge []string
- Status *string
-}
-
-type FeedbackEventInput struct {
- TenantID string
- OwnerUID string
- PersonaID string
- ContentPlanID string
- DraftID string
- Decision string
- Note string
- Snapshot string
-}
-
-type UseCase interface {
- CreateTopicCandidate(ctx context.Context, req TopicCandidateInput) (*TopicCandidateSummary, error)
- ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]TopicCandidateSummary, error)
- CreateContentPlan(ctx context.Context, req ContentPlanInput) (*ContentPlanSummary, error)
- UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch ContentPlanPatch) (*ContentPlanSummary, error)
- GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*ContentPlanSummary, error)
- ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]ContentPlanSummary, error)
- CreateFeedbackEvent(ctx context.Context, req FeedbackEventInput) (*FeedbackEventSummary, error)
- ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FeedbackEventSummary, error)
- CreateKnowledgeSource(ctx context.Context, req KnowledgeSourceInput) (*KnowledgeSourceSummary, error)
- ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeSourceSummary, error)
- ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]KnowledgeChunkSummary, error)
- CreateFormulaPool(ctx context.Context, req FormulaPoolInput) (*FormulaPoolSummary, error)
- ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]FormulaPoolSummary, error)
-}
diff --git a/old/backend/internal/model/content_ops/repository/mongo.go b/old/backend/internal/model/content_ops/repository/mongo.go
deleted file mode 100644
index 9444d99..0000000
--- a/old/backend/internal/model/content_ops/repository/mongo.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/content_ops/domain/entity"
- domrepo "haixun-backend/internal/model/content_ops/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- topics *mongo.Collection
- plans *mongo.Collection
- feedback *mongo.Collection
- sources *mongo.Collection
- chunks *mongo.Collection
- formulas *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{
- topics: db.Collection(entity.TopicCandidateCollectionName),
- plans: db.Collection(entity.ContentPlanCollectionName),
- feedback: db.Collection(entity.FeedbackEventCollectionName),
- sources: db.Collection(entity.KnowledgeSourceCollectionName),
- chunks: db.Collection(entity.KnowledgeChunkCollectionName),
- formulas: db.Collection(entity.FormulaPoolCollectionName),
- }
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.topics == nil || r.plans == nil || r.feedback == nil || r.sources == nil || r.chunks == nil || r.formulas == nil {
- return nil
- }
- if _, err := r.topics.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil {
- return err
- }
- if _, err := r.plans.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "topic_candidate_id", Value: 1}}}}); err != nil {
- return err
- }
- _, err := r.feedback.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "content_plan_id", Value: 1}}}, {Keys: bson.D{{Key: "draft_id", Value: 1}}}})
- if err != nil {
- return err
- }
- if _, err := r.sources.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}}); err != nil {
- return err
- }
- if _, err := r.chunks.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}}, {Keys: bson.D{{Key: "source_id", Value: 1}}}}); err != nil {
- return err
- }
- _, err = r.formulas.Indexes().CreateMany(ctx, []mongo.IndexModel{{Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "type", Value: 1}}}})
- return err
-}
-
-func personaFilter(tenantID, ownerUID, personaID string) bson.M {
- return bson.M{"tenant_id": strings.TrimSpace(tenantID), "owner_uid": strings.TrimSpace(ownerUID), "persona_id": strings.TrimSpace(personaID)}
-}
-
-func normalizeLimit(limit int) int64 {
- if limit <= 0 {
- limit = 50
- }
- if limit > 200 {
- limit = 200
- }
- return int64(limit)
-}
-
-func (r *mongoRepository) CreateTopicCandidate(ctx context.Context, item *entity.TopicCandidate) error {
- if r.topics == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- _, err := r.topics.InsertOne(ctx, item)
- return err
-}
-
-func (r *mongoRepository) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.TopicCandidate, error) {
- if r.topics == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.topics.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.TopicCandidate
- return out, cur.All(ctx, &out)
-}
-
-func (r *mongoRepository) CreateContentPlan(ctx context.Context, item *entity.ContentPlan) error {
- if r.plans == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- _, err := r.plans.InsertOne(ctx, item)
- return err
-}
-
-func (r *mongoRepository) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch map[string]any) (*entity.ContentPlan, error) {
- if r.plans == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = strings.TrimSpace(id)
- var out entity.ContentPlan
- err := r.plans.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, options.FindOneAndUpdate().SetReturnDocument(options.After)).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("找不到內容計畫")
- }
- return &out, err
-}
-
-func (r *mongoRepository) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*entity.ContentPlan, error) {
- if r.plans == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = strings.TrimSpace(id)
- var out entity.ContentPlan
- err := r.plans.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("找不到內容計畫")
- }
- return &out, err
-}
-
-func (r *mongoRepository) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.ContentPlan, error) {
- if r.plans == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.plans.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.ContentPlan
- return out, cur.All(ctx, &out)
-}
-
-func (r *mongoRepository) CreateFeedbackEvent(ctx context.Context, item *entity.FeedbackEvent) error {
- if r.feedback == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- _, err := r.feedback.InsertOne(ctx, item)
- return err
-}
-
-func (r *mongoRepository) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FeedbackEvent, error) {
- if r.feedback == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.feedback.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.FeedbackEvent
- return out, cur.All(ctx, &out)
-}
-
-func (r *mongoRepository) CreateKnowledgeSource(ctx context.Context, item *entity.KnowledgeSource, chunks []*entity.KnowledgeChunk) error {
- if r.sources == nil || r.chunks == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if _, err := r.sources.InsertOne(ctx, item); err != nil {
- return err
- }
- if len(chunks) == 0 {
- return nil
- }
- docs := make([]any, 0, len(chunks))
- for _, c := range chunks {
- docs = append(docs, c)
- }
- _, err := r.chunks.InsertMany(ctx, docs)
- return err
-}
-
-func (r *mongoRepository) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeSource, error) {
- if r.sources == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.sources.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.KnowledgeSource
- return out, cur.All(ctx, &out)
-}
-
-func (r *mongoRepository) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.KnowledgeChunk, error) {
- if r.chunks == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.chunks.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.KnowledgeChunk
- return out, cur.All(ctx, &out)
-}
-
-func (r *mongoRepository) CreateFormulaPool(ctx context.Context, item *entity.FormulaPool) error {
- if r.formulas == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- _, err := r.formulas.InsertOne(ctx, item)
- return err
-}
-
-func (r *mongoRepository) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.FormulaPool, error) {
- if r.formulas == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.formulas.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "weight", Value: -1}, {Key: "create_at", Value: -1}}).SetLimit(normalizeLimit(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.FormulaPool
- return out, cur.All(ctx, &out)
-}
diff --git a/old/backend/internal/model/content_ops/usecase/usecase.go b/old/backend/internal/model/content_ops/usecase/usecase.go
deleted file mode 100644
index 898c0b8..0000000
--- a/old/backend/internal/model/content_ops/usecase/usecase.go
+++ /dev/null
@@ -1,415 +0,0 @@
-package usecase
-
-import (
- "archive/zip"
- "bytes"
- "context"
- "encoding/base64"
- "encoding/xml"
- "math"
- "path/filepath"
- "regexp"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/content_ops/domain/entity"
- domrepo "haixun-backend/internal/model/content_ops/domain/repository"
- domusecase "haixun-backend/internal/model/content_ops/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type useCase struct{ repo domrepo.Repository }
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase { return &useCase{repo: repo} }
-
-func requireActor(tenantID, ownerUID, personaID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" || strings.TrimSpace(personaID) == "" {
- return app.For(code.Persona).InputMissingRequired("tenant_id, owner_uid, and persona_id are required")
- }
- return nil
-}
-
-func clampScore(n int) int {
- if n < 0 {
- return 0
- }
- if n > 100 {
- return 100
- }
- return n
-}
-
-func finalScore(heat, fit, interaction, extend, risk int) float64 {
- score := float64(clampScore(heat))*0.25 + float64(clampScore(fit))*0.30 + float64(clampScore(interaction))*0.15 + float64(clampScore(extend))*0.15 + 70*0.10 - float64(clampScore(risk))*0.20
- return math.Round(score*10) / 10
-}
-
-func (u *useCase) CreateTopicCandidate(ctx context.Context, req domusecase.TopicCandidateInput) (*domusecase.TopicCandidateSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- name := strings.TrimSpace(req.Name)
- if name == "" {
- return nil, app.For(code.Persona).InputMissingRequired("topic name is required")
- }
- now := clock.NowUnixNano()
- item := &entity.TopicCandidate{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Name: name, Source: strings.TrimSpace(req.Source), Category: strings.TrimSpace(req.Category), SeedQuery: strings.TrimSpace(req.SeedQuery), TrendReason: strings.TrimSpace(req.TrendReason), TrendKeywords: req.TrendKeywords, HeatScore: clampScore(req.HeatScore), FitScore: clampScore(req.FitScore), InteractionScore: clampScore(req.InteractionScore), ExtendScore: clampScore(req.ExtendScore), RiskScore: clampScore(req.RiskScore), RecommendedMissions: req.RecommendedMissions, Status: "candidate", CreateAt: now, UpdateAt: now}
- item.FinalScore = finalScore(item.HeatScore, item.FitScore, item.InteractionScore, item.ExtendScore, item.RiskScore)
- if err := u.repo.CreateTopicCandidate(ctx, item); err != nil {
- return nil, err
- }
- out := topicSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) ListTopicCandidates(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.TopicCandidateSummary, error) {
- items, err := u.repo.ListTopicCandidates(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.TopicCandidateSummary, 0, len(items))
- for _, item := range items {
- out = append(out, topicSummary(item))
- }
- return out, nil
-}
-
-func (u *useCase) CreateContentPlan(ctx context.Context, req domusecase.ContentPlanInput) (*domusecase.ContentPlanSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- if strings.TrimSpace(req.Topic) == "" || strings.TrimSpace(req.Mission) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("topic and mission are required")
- }
- now := clock.NowUnixNano()
- item := &entity.ContentPlan{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, TopicCandidateID: strings.TrimSpace(req.TopicCandidateID), Topic: strings.TrimSpace(req.Topic), Mission: strings.TrimSpace(req.Mission), TargetAudience: strings.TrimSpace(req.TargetAudience), Angle: strings.TrimSpace(req.Angle), OpeningType: strings.TrimSpace(req.OpeningType), BodyType: strings.TrimSpace(req.BodyType), Emotion: strings.TrimSpace(req.Emotion), EndingType: strings.TrimSpace(req.EndingType), CtaType: strings.TrimSpace(req.CtaType), RiskLevel: normalizeRisk(req.RiskLevel), RequiresHumanReview: req.RequiresHumanReview || normalizeRisk(req.RiskLevel) != "low", Avoid: req.Avoid, SelectedKnowledge: req.SelectedKnowledge, Status: "planned", CreateAt: now, UpdateAt: now}
- if err := u.repo.CreateContentPlan(ctx, item); err != nil {
- return nil, err
- }
- out := planSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) UpdateContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string, patch domusecase.ContentPlanPatch) (*domusecase.ContentPlanSummary, error) {
- m := map[string]any{"update_at": clock.NowUnixNano()}
- setString := func(key string, value *string) {
- if value != nil {
- m[key] = strings.TrimSpace(*value)
- }
- }
- setString("topic", patch.Topic)
- setString("mission", patch.Mission)
- setString("target_audience", patch.TargetAudience)
- setString("angle", patch.Angle)
- setString("opening_type", patch.OpeningType)
- setString("body_type", patch.BodyType)
- setString("emotion", patch.Emotion)
- setString("ending_type", patch.EndingType)
- setString("cta_type", patch.CtaType)
- setString("status", patch.Status)
- if patch.RiskLevel != nil {
- m["risk_level"] = normalizeRisk(*patch.RiskLevel)
- }
- if patch.RequiresHumanReview != nil {
- m["requires_human_review"] = *patch.RequiresHumanReview
- }
- if patch.Avoid != nil {
- m["avoid"] = patch.Avoid
- }
- if patch.SelectedKnowledge != nil {
- m["selected_knowledge"] = patch.SelectedKnowledge
- }
- item, err := u.repo.UpdateContentPlan(ctx, tenantID, ownerUID, personaID, id, m)
- if err != nil {
- return nil, err
- }
- out := planSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) GetContentPlan(ctx context.Context, tenantID, ownerUID, personaID, id string) (*domusecase.ContentPlanSummary, error) {
- item, err := u.repo.GetContentPlan(ctx, tenantID, ownerUID, personaID, id)
- if err != nil {
- return nil, err
- }
- out := planSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) ListContentPlans(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.ContentPlanSummary, error) {
- items, err := u.repo.ListContentPlans(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.ContentPlanSummary, 0, len(items))
- for _, item := range items {
- out = append(out, planSummary(item))
- }
- return out, nil
-}
-
-func (u *useCase) CreateFeedbackEvent(ctx context.Context, req domusecase.FeedbackEventInput) (*domusecase.FeedbackEventSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- decision := strings.TrimSpace(req.Decision)
- if decision == "" {
- return nil, app.For(code.Persona).InputMissingRequired("decision is required")
- }
- item := &entity.FeedbackEvent{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, ContentPlanID: strings.TrimSpace(req.ContentPlanID), DraftID: strings.TrimSpace(req.DraftID), Decision: decision, Note: strings.TrimSpace(req.Note), Snapshot: strings.TrimSpace(req.Snapshot), CreateAt: clock.NowUnixNano()}
- if err := u.repo.CreateFeedbackEvent(ctx, item); err != nil {
- return nil, err
- }
- out := feedbackSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) ListFeedbackEvents(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FeedbackEventSummary, error) {
- items, err := u.repo.ListFeedbackEvents(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.FeedbackEventSummary, 0, len(items))
- for _, item := range items {
- out = append(out, feedbackSummary(item))
- }
- return out, nil
-}
-
-func (u *useCase) CreateKnowledgeSource(ctx context.Context, req domusecase.KnowledgeSourceInput) (*domusecase.KnowledgeSourceSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- raw, err := decodeSourceContent(req)
- if err != nil {
- return nil, app.For(code.Persona).InputInvalidFormat(err.Error())
- }
- chunksText := splitChunks(raw, 900)
- now := clock.NowUnixNano()
- sourceID := uuid.NewString()
- item := &entity.KnowledgeSource{ID: sourceID, TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceType: strings.TrimSpace(req.SourceType), Title: strings.TrimSpace(req.Title), Filename: strings.TrimSpace(req.Filename), RawContent: raw, ParsedStatus: "parsed", ChunkCount: len(chunksText), CreateAt: now}
- chunks := make([]*entity.KnowledgeChunk, 0, len(chunksText))
- for _, text := range chunksText {
- chunks = append(chunks, &entity.KnowledgeChunk{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, SourceID: sourceID, Content: text, Topics: inferTopics(text), StyleTags: inferStyleTags(text), RiskLevel: inferRisk(text), CreateAt: now})
- }
- if err := u.repo.CreateKnowledgeSource(ctx, item, chunks); err != nil {
- return nil, err
- }
- out := sourceSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) ListKnowledgeSources(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeSourceSummary, error) {
- items, err := u.repo.ListKnowledgeSources(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.KnowledgeSourceSummary, 0, len(items))
- for _, item := range items {
- out = append(out, sourceSummary(item))
- }
- return out, nil
-}
-
-func (u *useCase) ListKnowledgeChunks(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.KnowledgeChunkSummary, error) {
- items, err := u.repo.ListKnowledgeChunks(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.KnowledgeChunkSummary, 0, len(items))
- for _, item := range items {
- out = append(out, chunkSummary(item))
- }
- return out, nil
-}
-
-func (u *useCase) CreateFormulaPool(ctx context.Context, req domusecase.FormulaPoolInput) (*domusecase.FormulaPoolSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Name) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("type and name are required")
- }
- now := clock.NowUnixNano()
- weight := req.Weight
- if weight <= 0 {
- weight = 50
- }
- item := &entity.FormulaPool{ID: uuid.NewString(), TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID, Type: strings.TrimSpace(req.Type), Name: strings.TrimSpace(req.Name), Pattern: strings.TrimSpace(req.Pattern), UseCases: req.UseCases, Avoid: req.Avoid, Weight: weight, CreateAt: now, UpdateAt: now}
- if err := u.repo.CreateFormulaPool(ctx, item); err != nil {
- return nil, err
- }
- out := formulaSummary(*item)
- return &out, nil
-}
-
-func (u *useCase) ListFormulaPools(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.FormulaPoolSummary, error) {
- items, err := u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- if len(items) == 0 {
- _ = u.seedDefaultFormulas(ctx, tenantID, ownerUID, personaID)
- items, _ = u.repo.ListFormulaPools(ctx, tenantID, ownerUID, personaID, limit)
- }
- out := make([]domusecase.FormulaPoolSummary, 0, len(items))
- for _, item := range items {
- out = append(out, formulaSummary(item))
- }
- return out, nil
-}
-
-func (u *useCase) seedDefaultFormulas(ctx context.Context, tenantID, ownerUID, personaID string) error {
- defs := []domusecase.FormulaPoolInput{{Type: "opening", Name: "問題開場", Pattern: "用讀者正在問的具體問題開場", UseCases: []string{"互動文"}, Weight: 70}, {Type: "body", Name: "先共鳴再整理", Pattern: "先接住情緒,再整理 2-3 個可執行觀點", UseCases: []string{"共鳴文", "知識文"}, Weight: 75}, {Type: "emotion", Name: "被理解感", Pattern: "講出讀者不一定說出口的焦慮或委屈", UseCases: []string{"情緒共鳴文"}, Weight: 70}, {Type: "cta", Name: "經驗募集型", Pattern: "邀請讀者補充自己的經驗,不硬導購", UseCases: []string{"互動文"}, Weight: 65}}
- for _, def := range defs {
- def.TenantID = tenantID
- def.OwnerUID = ownerUID
- def.PersonaID = personaID
- if _, err := u.CreateFormulaPool(ctx, def); err != nil {
- return err
- }
- }
- return nil
-}
-
-func decodeSourceContent(req domusecase.KnowledgeSourceInput) (string, error) {
- if strings.TrimSpace(req.ContentBase64) != "" {
- data, err := base64.StdEncoding.DecodeString(req.ContentBase64)
- if err != nil {
- return "", err
- }
- if strings.EqualFold(filepath.Ext(req.Filename), ".xlsx") {
- return parseXLSX(data)
- }
- return string(data), nil
- }
- return strings.TrimSpace(req.Content), nil
-}
-
-type xlsxText struct {
- T string `xml:",chardata"`
-}
-
-func parseXLSX(data []byte) (string, error) {
- zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
- if err != nil {
- return "", err
- }
- var b strings.Builder
- for _, f := range zr.File {
- if !strings.HasPrefix(f.Name, "xl/sharedStrings") && !strings.HasPrefix(f.Name, "xl/worksheets") {
- continue
- }
- rc, err := f.Open()
- if err != nil {
- continue
- }
- var texts []xlsxText
- _ = xml.NewDecoder(rc).Decode(&texts)
- _ = rc.Close()
- for _, t := range texts {
- if s := strings.TrimSpace(t.T); s != "" {
- b.WriteString(s)
- b.WriteString("\n")
- }
- }
- }
- text := strings.TrimSpace(b.String())
- if text == "" {
- return "", nil
- }
- return text, nil
-}
-
-func splitChunks(raw string, max int) []string {
- raw = strings.TrimSpace(raw)
- if raw == "" {
- return nil
- }
- paras := strings.Split(raw, "\n")
- var out []string
- var b strings.Builder
- for _, p := range paras {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- if b.Len()+len([]rune(p)) > max && b.Len() > 0 {
- out = append(out, b.String())
- b.Reset()
- }
- if b.Len() > 0 {
- b.WriteString("\n")
- }
- b.WriteString(p)
- }
- if b.Len() > 0 {
- out = append(out, b.String())
- }
- return out
-}
-
-var topicRE = regexp.MustCompile(`[A-Za-z0-9_\p{Han}]{2,12}`)
-
-func inferTopics(text string) []string {
- seen := map[string]bool{}
- var out []string
- for _, m := range topicRE.FindAllString(text, 20) {
- if !seen[m] && len(out) < 8 {
- seen[m] = true
- out = append(out, m)
- }
- }
- return out
-}
-func inferStyleTags(text string) []string {
- var out []string
- if strings.Contains(text, "我") {
- out = append(out, "第一人稱")
- }
- if strings.Contains(text, "?") || strings.Contains(text, "?") {
- out = append(out, "提問")
- }
- if strings.Contains(text, "。") {
- out = append(out, "自然標點")
- }
- return out
-}
-func inferRisk(text string) string {
- lower := strings.ToLower(text)
- for _, k := range []string{"醫", "藥", "amh", "投資", "法律"} {
- if strings.Contains(lower, k) {
- return "medium"
- }
- }
- return "low"
-}
-
-func normalizeRisk(raw string) string {
- raw = strings.ToLower(strings.TrimSpace(raw))
- if raw == "high" || raw == "low" {
- return raw
- }
- return "medium"
-}
-func topicSummary(item entity.TopicCandidate) domusecase.TopicCandidateSummary {
- return domusecase.TopicCandidateSummary{ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Source: item.Source, Category: item.Category, SeedQuery: item.SeedQuery, TrendReason: item.TrendReason, TrendKeywords: item.TrendKeywords, HeatScore: item.HeatScore, FitScore: item.FitScore, InteractionScore: item.InteractionScore, ExtendScore: item.ExtendScore, RiskScore: item.RiskScore, FinalScore: item.FinalScore, RecommendedMissions: item.RecommendedMissions, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
-}
-func planSummary(item entity.ContentPlan) domusecase.ContentPlanSummary {
- return domusecase.ContentPlanSummary{ID: item.ID, PersonaID: item.PersonaID, TopicCandidateID: item.TopicCandidateID, Topic: item.Topic, Mission: item.Mission, TargetAudience: item.TargetAudience, Angle: item.Angle, OpeningType: item.OpeningType, BodyType: item.BodyType, Emotion: item.Emotion, EndingType: item.EndingType, CtaType: item.CtaType, RiskLevel: item.RiskLevel, RequiresHumanReview: item.RequiresHumanReview, Avoid: item.Avoid, SelectedKnowledge: item.SelectedKnowledge, Status: item.Status, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
-}
-func feedbackSummary(item entity.FeedbackEvent) domusecase.FeedbackEventSummary {
- return domusecase.FeedbackEventSummary{ID: item.ID, PersonaID: item.PersonaID, ContentPlanID: item.ContentPlanID, DraftID: item.DraftID, Decision: item.Decision, Note: item.Note, Snapshot: item.Snapshot, CreateAt: item.CreateAt}
-}
-func sourceSummary(item entity.KnowledgeSource) domusecase.KnowledgeSourceSummary {
- return domusecase.KnowledgeSourceSummary{ID: item.ID, PersonaID: item.PersonaID, SourceType: item.SourceType, Title: item.Title, Filename: item.Filename, ParsedStatus: item.ParsedStatus, ChunkCount: item.ChunkCount, CreateAt: item.CreateAt}
-}
-func chunkSummary(item entity.KnowledgeChunk) domusecase.KnowledgeChunkSummary {
- return domusecase.KnowledgeChunkSummary{ID: item.ID, PersonaID: item.PersonaID, SourceID: item.SourceID, Content: item.Content, Topics: item.Topics, StyleTags: item.StyleTags, RiskLevel: item.RiskLevel, CreateAt: item.CreateAt}
-}
-func formulaSummary(item entity.FormulaPool) domusecase.FormulaPoolSummary {
- return domusecase.FormulaPoolSummary{ID: item.ID, PersonaID: item.PersonaID, Type: item.Type, Name: item.Name, Pattern: item.Pattern, UseCases: item.UseCases, Avoid: item.Avoid, Weight: item.Weight, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt}
-}
diff --git a/old/backend/internal/model/copy_draft/domain/entity/draft.go b/old/backend/internal/model/copy_draft/domain/entity/draft.go
deleted file mode 100644
index 2fb9a47..0000000
--- a/old/backend/internal/model/copy_draft/domain/entity/draft.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package entity
-
-const CollectionName = "copy_drafts"
-
-const (
- DraftTypeViralReplica = "viral-replica"
- DraftTypeReplicate = "replicate"
- DraftTypeMatrix = "matrix"
- DraftTypeFormula = "formula"
-)
-
-type CopyDraft struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- ContentPlanID string `bson:"content_plan_id,omitempty"`
- CopyMissionID string `bson:"copy_mission_id,omitempty"`
- ScanPostID string `bson:"scan_post_id,omitempty"`
- FormulaID string `bson:"formula_id,omitempty"`
- DraftType string `bson:"draft_type"`
- MatrixBatchID string `bson:"matrix_batch_id,omitempty"`
- SortOrder int `bson:"sort_order,omitempty"`
- Text string `bson:"text"`
- TopicTag string `bson:"topic_tag,omitempty"`
- Angle string `bson:"angle,omitempty"`
- Hook string `bson:"hook,omitempty"`
- Rationale string `bson:"rationale,omitempty"`
- ReferenceNotes string `bson:"reference_notes,omitempty"`
- Sources []string `bson:"sources,omitempty"`
- AiScore int `bson:"ai_score,omitempty"`
- FormulaScore int `bson:"formula_score,omitempty"`
- BrandFitScore int `bson:"brand_fit_score,omitempty"`
- RiskScore int `bson:"risk_score,omitempty"`
- SimilarityScore int `bson:"similarity_score,omitempty"`
- EngagementPotential int `bson:"engagement_potential,omitempty"`
- FreshnessScore int `bson:"freshness_score,omitempty"`
- ReviewSuggestion string `bson:"review_suggestion,omitempty"`
- Status string `bson:"status,omitempty"`
- PublishQueueID string `bson:"publish_queue_id,omitempty"`
- PublishedMediaID string `bson:"published_media_id,omitempty"`
- PublishedPermalink string `bson:"published_permalink,omitempty"`
- PublishedAt int64 `bson:"published_at,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
diff --git a/old/backend/internal/model/copy_draft/domain/repository/repository.go b/old/backend/internal/model/copy_draft/domain/repository/repository.go
deleted file mode 100644
index 61d35ce..0000000
--- a/old/backend/internal/model/copy_draft/domain/repository/repository.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/copy_draft/domain/entity"
-)
-
-type InboxFilter struct {
- TenantID string
- OwnerUID string
- PersonaID string
- Status string
- RangeStart int64
- RangeEnd int64
- Page int
- PageSize int
-}
-
-type InboxListResult struct {
- Items []entity.CopyDraft
- Total int64
-}
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, draft *entity.CopyDraft) error
- CreateMany(ctx context.Context, drafts []*entity.CopyDraft) error
- Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*entity.CopyDraft, error)
- Update(ctx context.Context, tenantID, ownerUID, personaID, draftID string, patch map[string]interface{}) (*entity.CopyDraft, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error
- DeleteMany(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string, draftIDs []string) (int64, error)
- DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error
- DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
- List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error)
- ListInbox(ctx context.Context, filter InboxFilter) (*InboxListResult, error)
- ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error)
- ReplaceMissionMatrix(
- ctx context.Context,
- tenantID, ownerUID, personaID, missionID string,
- drafts []*entity.CopyDraft,
- ) error
- // PruneOldestPendingBeyond deletes oldest idle pending drafts (no mission / queue / publish) beyond keep.
- PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error)
-}
diff --git a/old/backend/internal/model/copy_draft/domain/usecase/usecase.go b/old/backend/internal/model/copy_draft/domain/usecase/usecase.go
deleted file mode 100644
index ac717cd..0000000
--- a/old/backend/internal/model/copy_draft/domain/usecase/usecase.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package usecase
-
-import (
- "context"
-)
-
-type CopyDraftSummary struct {
- ID string
- PersonaID string
- ContentPlanID string
- CopyMissionID string
- ScanPostID string
- FormulaID string
- DraftType string
- SortOrder int
- Text string
- TopicTag string
- Angle string
- Hook string
- Rationale string
- ReferenceNotes string
- Sources []string
- AiScore int
- FormulaScore int
- BrandFitScore int
- RiskScore int
- SimilarityScore int
- EngagementPotential int
- FreshnessScore int
- ReviewSuggestion string
- Status string
- PublishQueueID string
- PublishedMediaID string
- PublishedPermalink string
- PublishedAt int64
- CreateAt int64
-}
-
-type MarkPublishedRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- DraftID string
- MediaID string
- Permalink string
-}
-
-type MarkScheduledRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- DraftID string
- QueueID string
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- ContentPlanID string
- CopyMissionID string
- ScanPostID string
- FormulaID string
- DraftType string
- SortOrder int
- Text string
- TopicTag string
- Angle string
- Hook string
- Rationale string
- ReferenceNotes string
- Sources []string
- AiScore int
- FormulaScore int
- BrandFitScore int
- RiskScore int
- SimilarityScore int
- EngagementPotential int
- FreshnessScore int
- ReviewSuggestion string
-}
-
-type CreateManyRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- Drafts []CreateRequest
-}
-
-type CopyDraftPatch struct {
- Text *string
- TopicTag *string
- Hook *string
- Angle *string
- Rationale *string
- Status *string
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- DraftID string
- Patch CopyDraftPatch
-}
-
-type InboxListRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- Status string
- RangeStart int64
- RangeEnd int64
- Page int
- PageSize int
-}
-
-type InboxListResult struct {
- Items []CopyDraftSummary
- Total int64
-}
-
-type DeleteMissionMatrixDraftsRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- MissionID string
- DraftIDs []string
-}
-
-type UseCase interface {
- Create(ctx context.Context, req CreateRequest) (*CopyDraftSummary, error)
- CreateMany(ctx context.Context, req CreateManyRequest) ([]CopyDraftSummary, error)
- ReplaceMissionMatrix(ctx context.Context, tenantID, ownerUID, personaID, missionID string, drafts []CreateRequest) ([]CopyDraftSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*CopyDraftSummary, error)
- Update(ctx context.Context, req UpdateRequest) (*CopyDraftSummary, error)
- MarkPublished(ctx context.Context, req MarkPublishedRequest) (*CopyDraftSummary, error)
- MarkScheduled(ctx context.Context, req MarkScheduledRequest) (*CopyDraftSummary, error)
- List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]CopyDraftSummary, error)
- ListInbox(ctx context.Context, req InboxListRequest) (*InboxListResult, error)
- PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error)
- ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]CopyDraftSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error
- DeleteMissionMatrixDrafts(ctx context.Context, req DeleteMissionMatrixDraftsRequest) (int, error)
- ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
-}
diff --git a/old/backend/internal/model/copy_draft/repository/mongo.go b/old/backend/internal/model/copy_draft/repository/mongo.go
deleted file mode 100644
index aa15a4d..0000000
--- a/old/backend/internal/model/copy_draft/repository/mongo.go
+++ /dev/null
@@ -1,532 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/copy_draft/domain/entity"
- domrepo "haixun-backend/internal/model/copy_draft/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
- "go.mongodb.org/mongo-driver/mongo/writeconcern"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "persona_id", Value: 1},
- {Key: "create_at", Value: -1},
- },
- },
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "persona_id", Value: 1},
- {Key: "content_plan_id", Value: 1},
- {Key: "create_at", Value: -1},
- },
- },
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "persona_id", Value: 1},
- {Key: "copy_mission_id", Value: 1},
- {Key: "sort_order", Value: 1},
- },
- },
- })
- return err
-}
-
-func personaFilter(tenantID, ownerUID, personaID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "persona_id": strings.TrimSpace(personaID),
- }
-}
-
-func (r *mongoRepository) CreateMany(ctx context.Context, drafts []*entity.CopyDraft) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if len(drafts) == 0 {
- return nil
- }
- docs := make([]any, 0, len(drafts))
- for _, draft := range drafts {
- if draft == nil {
- continue
- }
- docs = append(docs, draft)
- }
- if len(docs) == 0 {
- return nil
- }
- _, err := r.collection.InsertMany(ctx, docs)
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, draft *entity.CopyDraft) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if draft == nil {
- return app.For(code.Persona).InputMissingRequired("draft is required")
- }
- _, err := r.collection.InsertOne(ctx, draft)
- return err
-}
-
-func inboxStatusFilter(status string) bson.M {
- switch strings.TrimSpace(status) {
- case "draft":
- return bson.M{
- "$and": []bson.M{
- {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}},
- {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}},
- },
- }
- case "scheduled":
- return bson.M{
- "publish_queue_id": bson.M{"$ne": ""},
- "$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}},
- }
- case "published":
- return bson.M{"published_at": bson.M{"$gt": 0}}
- default:
- return bson.M{}
- }
-}
-
-func normalizeInboxPage(page, pageSize int) (int, int) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 12
- }
- if pageSize > 100 {
- pageSize = 100
- }
- return page, pageSize
-}
-
-func (r *mongoRepository) ListInbox(ctx context.Context, filter domrepo.InboxFilter) (*domrepo.InboxListResult, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- page, pageSize := normalizeInboxPage(filter.Page, filter.PageSize)
- mongoFilter := personaFilter(filter.TenantID, filter.OwnerUID, filter.PersonaID)
- if extra := inboxStatusFilter(filter.Status); len(extra) > 0 {
- for k, v := range extra {
- mongoFilter[k] = v
- }
- }
- if filter.RangeStart > 0 || filter.RangeEnd > 0 {
- rangeClauses := make([]bson.M, 0, 3)
- createRange := bson.M{}
- if filter.RangeStart > 0 {
- createRange["$gte"] = filter.RangeStart
- }
- if filter.RangeEnd > 0 {
- createRange["$lte"] = filter.RangeEnd
- }
- if len(createRange) > 0 {
- rangeClauses = append(rangeClauses, bson.M{"create_at": createRange})
- }
- publishedRange := bson.M{}
- if filter.RangeStart > 0 {
- publishedRange["$gte"] = filter.RangeStart
- }
- if filter.RangeEnd > 0 {
- publishedRange["$lte"] = filter.RangeEnd
- }
- if len(publishedRange) > 0 {
- rangeClauses = append(rangeClauses, bson.M{"published_at": publishedRange})
- }
- if len(rangeClauses) > 0 {
- mongoFilter["$or"] = rangeClauses
- }
- }
- total, err := r.collection.CountDocuments(ctx, mongoFilter)
- if err != nil {
- return nil, err
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "create_at", Value: -1}}).
- SetSkip(int64((page - 1) * pageSize)).
- SetLimit(int64(pageSize))
- cur, err := r.collection.Find(ctx, mongoFilter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.CopyDraft
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return &domrepo.InboxListResult{Items: out, Total: total}, nil
-}
-
-func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]entity.CopyDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- if limit > 200 {
- limit = 200
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "sort_order", Value: 1}, {Key: "create_at", Value: -1}}).
- SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, personaFilter(tenantID, ownerUID, personaID), opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.CopyDraft
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*entity.CopyDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- draftID = strings.TrimSpace(draftID)
- if draftID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("draft_id is required")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = draftID
- var out entity.CopyDraft
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("copy draft not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, personaID, draftID string, patch map[string]interface{}) (*entity.CopyDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("patch is required")
- }
- draftID = strings.TrimSpace(draftID)
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = draftID
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.CopyDraft
- err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("copy draft not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- draftID = strings.TrimSpace(draftID)
- if draftID == "" {
- return app.For(code.Persona).InputMissingRequired("draft_id is required")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = draftID
- res, err := r.collection.DeleteOne(ctx, filter)
- if err != nil {
- return err
- }
- if res.DeletedCount == 0 {
- return app.For(code.Persona).ResNotFound("copy draft not found")
- }
- return nil
-}
-
-func (r *mongoRepository) DeleteMany(
- ctx context.Context,
- tenantID, ownerUID, personaID, missionID, draftType string,
- draftIDs []string,
-) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return 0, app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["copy_mission_id"] = missionID
- if draftType = strings.TrimSpace(draftType); draftType != "" {
- filter["draft_type"] = draftType
- }
- ids := make([]string, 0, len(draftIDs))
- for _, id := range draftIDs {
- id = strings.TrimSpace(id)
- if id == "" {
- continue
- }
- ids = append(ids, id)
- }
- if len(ids) > 0 {
- filter["_id"] = bson.M{"$in": ids}
- }
- res, err := r.collection.DeleteMany(ctx, filter)
- if err != nil {
- return 0, err
- }
- return res.DeletedCount, nil
-}
-
-func (r *mongoRepository) DeleteByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["copy_mission_id"] = missionID
- _, err := r.collection.DeleteMany(ctx, filter)
- return err
-}
-
-func (r *mongoRepository) DeleteByMissionAndType(ctx context.Context, tenantID, ownerUID, personaID, missionID, draftType string) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- missionID = strings.TrimSpace(missionID)
- draftType = strings.TrimSpace(draftType)
- if missionID == "" || draftType == "" {
- return app.For(code.Persona).InputMissingRequired("copy_mission_id and draft_type are required")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["copy_mission_id"] = missionID
- filter["draft_type"] = draftType
- _, err := r.collection.DeleteMany(ctx, filter)
- return err
-}
-
-func matrixDraftFilter(tenantID, ownerUID, personaID, missionID string) bson.M {
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["copy_mission_id"] = strings.TrimSpace(missionID)
- filter["draft_type"] = entity.DraftTypeMatrix
- return filter
-}
-
-func (r *mongoRepository) ReplaceMissionMatrix(
- ctx context.Context,
- tenantID, ownerUID, personaID, missionID string,
- drafts []*entity.CopyDraft,
-) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- if err := r.replaceMissionMatrixWithTransaction(ctx, tenantID, ownerUID, personaID, missionID, drafts); err == nil {
- return nil
- }
- return r.replaceMissionMatrixInsertFirst(ctx, tenantID, ownerUID, personaID, missionID, drafts)
-}
-
-func (r *mongoRepository) replaceMissionMatrixWithTransaction(
- ctx context.Context,
- tenantID, ownerUID, personaID, missionID string,
- drafts []*entity.CopyDraft,
-) error {
- client := r.collection.Database().Client()
- session, err := client.StartSession()
- if err != nil {
- return err
- }
- defer session.EndSession(ctx)
-
- wc := writeconcern.Majority()
- txnOpts := options.Transaction().SetWriteConcern(wc)
- _, err = session.WithTransaction(ctx, func(sessCtx mongo.SessionContext) (any, error) {
- filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID)
- if _, err := r.collection.DeleteMany(sessCtx, filter); err != nil {
- return nil, err
- }
- if len(drafts) == 0 {
- return nil, nil
- }
- docs := make([]any, 0, len(drafts))
- for _, draft := range drafts {
- if draft == nil {
- continue
- }
- docs = append(docs, draft)
- }
- if len(docs) == 0 {
- return nil, nil
- }
- if _, err := r.collection.InsertMany(sessCtx, docs); err != nil {
- return nil, err
- }
- return nil, nil
- }, txnOpts)
- return err
-}
-
-func (r *mongoRepository) replaceMissionMatrixInsertFirst(
- ctx context.Context,
- tenantID, ownerUID, personaID, missionID string,
- drafts []*entity.CopyDraft,
-) error {
- batchID := ""
- if len(drafts) > 0 {
- batchID = strings.TrimSpace(drafts[0].MatrixBatchID)
- }
- if batchID == "" {
- filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID)
- if _, err := r.collection.DeleteMany(ctx, filter); err != nil {
- return err
- }
- if len(drafts) == 0 {
- return nil
- }
- return r.CreateMany(ctx, drafts)
- }
- if err := r.CreateMany(ctx, drafts); err != nil {
- return err
- }
- filter := matrixDraftFilter(tenantID, ownerUID, personaID, missionID)
- filter["matrix_batch_id"] = bson.M{"$ne": batchID}
- _, err := r.collection.DeleteMany(ctx, filter)
- return err
-}
-
-func pendingIdleDraftFilter(tenantID, ownerUID, personaID string) bson.M {
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["$and"] = []bson.M{
- {"$or": []bson.M{{"publish_queue_id": ""}, {"publish_queue_id": bson.M{"$exists": false}}}},
- {"$or": []bson.M{{"published_at": 0}, {"published_at": bson.M{"$exists": false}}}},
- {"$or": []bson.M{{"copy_mission_id": ""}, {"copy_mission_id": bson.M{"$exists": false}}}},
- }
- return filter
-}
-
-func (r *mongoRepository) PruneOldestPendingBeyond(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if keep <= 0 {
- keep = 40
- }
- filter := pendingIdleDraftFilter(tenantID, ownerUID, personaID)
- total, err := r.collection.CountDocuments(ctx, filter)
- if err != nil {
- return 0, err
- }
- excess := total - int64(keep)
- if excess <= 0 {
- return 0, nil
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "create_at", Value: 1}}).
- SetLimit(excess).
- SetProjection(bson.M{"_id": 1})
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return 0, err
- }
- defer cur.Close(ctx)
- ids := make([]string, 0, excess)
- for cur.Next(ctx) {
- var row struct {
- ID string `bson:"_id"`
- }
- if err := cur.Decode(&row); err != nil {
- return 0, err
- }
- if row.ID != "" {
- ids = append(ids, row.ID)
- }
- }
- if err := cur.Err(); err != nil {
- return 0, err
- }
- if len(ids) == 0 {
- return 0, nil
- }
- deleteFilter := personaFilter(tenantID, ownerUID, personaID)
- deleteFilter["_id"] = bson.M{"$in": ids}
- res, err := r.collection.DeleteMany(ctx, deleteFilter)
- if err != nil {
- return 0, err
- }
- return res.DeletedCount, nil
-}
-
-func (r *mongoRepository) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]entity.CopyDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- if limit <= 0 {
- limit = 50
- }
- if limit > 200 {
- limit = 200
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["copy_mission_id"] = missionID
- opts := options.Find().
- SetSort(bson.D{{Key: "sort_order", Value: 1}, {Key: "create_at", Value: -1}}).
- SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.CopyDraft
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
diff --git a/old/backend/internal/model/copy_draft/usecase/usecase.go b/old/backend/internal/model/copy_draft/usecase/usecase.go
deleted file mode 100644
index e757cef..0000000
--- a/old/backend/internal/model/copy_draft/usecase/usecase.go
+++ /dev/null
@@ -1,524 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- libcopy "haixun-backend/internal/library/copymission"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/threadspost"
- "haixun-backend/internal/model/copy_draft/domain/entity"
- domrepo "haixun-backend/internal/model/copy_draft/domain/repository"
- domusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
-
- "github.com/google/uuid"
- goredis "github.com/redis/go-redis/v9"
-)
-
-const defaultIdleDraftKeep = 40
-
-type copyDraftUseCase struct {
- repo domrepo.Repository
- redis *goredis.Client
-}
-
-func NewUseCase(repo domrepo.Repository, redis *goredis.Client) domusecase.UseCase {
- return ©DraftUseCase{repo: repo, redis: redis}
-}
-
-func (u *copyDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.CopyDraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- text := threadspost.FormatDraftText(req.Text)
- if text == "" {
- return nil, app.For(code.Persona).InputMissingRequired("draft text is required")
- }
- draftType := strings.TrimSpace(req.DraftType)
- if draftType == "" {
- draftType = entity.DraftTypeViralReplica
- }
- item := &entity.CopyDraft{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.PersonaID,
- ContentPlanID: strings.TrimSpace(req.ContentPlanID),
- CopyMissionID: strings.TrimSpace(req.CopyMissionID),
- ScanPostID: strings.TrimSpace(req.ScanPostID),
- FormulaID: strings.TrimSpace(req.FormulaID),
- DraftType: draftType,
- SortOrder: req.SortOrder,
- Text: text,
- TopicTag: normalizeTopicTag(req.TopicTag),
- Angle: strings.TrimSpace(req.Angle),
- Hook: strings.TrimSpace(req.Hook),
- Rationale: strings.TrimSpace(req.Rationale),
- ReferenceNotes: strings.TrimSpace(req.ReferenceNotes),
- Sources: req.Sources,
- AiScore: clampScore(req.AiScore),
- FormulaScore: clampScore(req.FormulaScore),
- BrandFitScore: clampScore(req.BrandFitScore),
- RiskScore: clampScore(req.RiskScore),
- SimilarityScore: clampScore(req.SimilarityScore),
- EngagementPotential: clampScore(req.EngagementPotential),
- FreshnessScore: clampScore(req.FreshnessScore),
- ReviewSuggestion: strings.TrimSpace(req.ReviewSuggestion),
- Status: "pending",
- CreateAt: clock.NowUnixNano(),
- }
- if err := u.repo.Create(ctx, item); err != nil {
- return nil, err
- }
- u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID)
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *copyDraftUseCase) CreateMany(ctx context.Context, req domusecase.CreateManyRequest) ([]domusecase.CopyDraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- if len(req.Drafts) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("drafts is required")
- }
- items := make([]*entity.CopyDraft, 0, len(req.Drafts))
- for idx, draftReq := range req.Drafts {
- text := threadspost.FormatDraftText(draftReq.Text)
- if text == "" {
- continue
- }
- draftType := strings.TrimSpace(draftReq.DraftType)
- if draftType == "" {
- draftType = entity.DraftTypeMatrix
- }
- sortOrder := draftReq.SortOrder
- if sortOrder == 0 {
- sortOrder = idx + 1
- }
- items = append(items, &entity.CopyDraft{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.PersonaID,
- ContentPlanID: strings.TrimSpace(draftReq.ContentPlanID),
- CopyMissionID: strings.TrimSpace(draftReq.CopyMissionID),
- ScanPostID: strings.TrimSpace(draftReq.ScanPostID),
- DraftType: draftType,
- SortOrder: sortOrder,
- Text: text,
- TopicTag: normalizeTopicTag(draftReq.TopicTag),
- Angle: strings.TrimSpace(draftReq.Angle),
- Hook: strings.TrimSpace(draftReq.Hook),
- Rationale: strings.TrimSpace(draftReq.Rationale),
- ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes),
- Sources: draftReq.Sources,
- AiScore: clampScore(draftReq.AiScore),
- FormulaScore: clampScore(draftReq.FormulaScore),
- BrandFitScore: clampScore(draftReq.BrandFitScore),
- RiskScore: clampScore(draftReq.RiskScore),
- SimilarityScore: clampScore(draftReq.SimilarityScore),
- EngagementPotential: clampScore(draftReq.EngagementPotential),
- FreshnessScore: clampScore(draftReq.FreshnessScore),
- ReviewSuggestion: strings.TrimSpace(draftReq.ReviewSuggestion),
- Status: "pending",
- CreateAt: clock.NowUnixNano(),
- })
- }
- if len(items) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("draft text is required")
- }
- if err := u.repo.CreateMany(ctx, items); err != nil {
- return nil, err
- }
- u.pruneIdleDraftsBestEffort(ctx, req.TenantID, req.OwnerUID, req.PersonaID)
- out := make([]domusecase.CopyDraftSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(*item))
- }
- return out, nil
-}
-
-func (u *copyDraftUseCase) Get(ctx context.Context, tenantID, ownerUID, personaID, draftID string) (*domusecase.CopyDraftSummary, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, personaID, draftID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *copyDraftUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.CopyDraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- draftID := strings.TrimSpace(req.DraftID)
- if draftID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("draft_id is required")
- }
- patch := map[string]interface{}{}
- if req.Patch.Text != nil {
- text := strings.TrimSpace(*req.Patch.Text)
- if text == "" {
- return nil, app.For(code.Persona).InputMissingRequired("draft text cannot be empty")
- }
- patch["text"] = text
- }
- if req.Patch.TopicTag != nil {
- patch["topic_tag"] = normalizeTopicTag(*req.Patch.TopicTag)
- }
- if req.Patch.Hook != nil {
- patch["hook"] = strings.TrimSpace(*req.Patch.Hook)
- }
- if req.Patch.Angle != nil {
- patch["angle"] = strings.TrimSpace(*req.Patch.Angle)
- }
- if req.Patch.Rationale != nil {
- patch["rationale"] = strings.TrimSpace(*req.Patch.Rationale)
- }
- if req.Patch.Status != nil {
- status := strings.TrimSpace(*req.Patch.Status)
- if status != "" && status != "pending" && status != "ready" && status != "rejected" && status != "scheduled" {
- return nil, app.For(code.Persona).InputMissingRequired("status must be pending, ready, rejected or scheduled")
- }
- if status != "" {
- patch["status"] = status
- }
- }
- if len(patch) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("no fields to update")
- }
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, patch)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *copyDraftUseCase) MarkPublished(ctx context.Context, req domusecase.MarkPublishedRequest) (*domusecase.CopyDraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- draftID := strings.TrimSpace(req.DraftID)
- mediaID := strings.TrimSpace(req.MediaID)
- if draftID == "" || mediaID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("draft_id and media_id are required")
- }
- now := clock.NowUnixNano()
- patch := map[string]interface{}{
- "status": "published",
- "publish_queue_id": "",
- "published_media_id": mediaID,
- "published_permalink": strings.TrimSpace(req.Permalink),
- "published_at": now,
- }
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, patch)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *copyDraftUseCase) MarkScheduled(ctx context.Context, req domusecase.MarkScheduledRequest) (*domusecase.CopyDraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- draftID := strings.TrimSpace(req.DraftID)
- queueID := strings.TrimSpace(req.QueueID)
- if draftID == "" || queueID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("draft_id and queue_id are required")
- }
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, draftID, map[string]interface{}{
- "status": "scheduled",
- "publish_queue_id": queueID,
- })
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *copyDraftUseCase) ReplaceMissionMatrix(
- ctx context.Context,
- tenantID, ownerUID, personaID, missionID string,
- drafts []domusecase.CreateRequest,
-) ([]domusecase.CopyDraftSummary, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return nil, app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- var out []domusecase.CopyDraftSummary
- err := libcopy.WithMatrixReplaceLock(ctx, u.redis, tenantID, missionID, ownerUID, func() error {
- items, buildErr := buildMatrixDraftEntities(tenantID, ownerUID, personaID, missionID, drafts)
- if buildErr != nil {
- return buildErr
- }
- if replaceErr := u.repo.ReplaceMissionMatrix(ctx, tenantID, ownerUID, personaID, missionID, items); replaceErr != nil {
- return replaceErr
- }
- out = summariesFromEntities(items)
- return nil
- })
- if err != nil {
- if strings.Contains(err.Error(), "already in progress") {
- return nil, app.For(code.Persona).ResInvalidState("內容矩陣正在寫入中,請稍後再試")
- }
- return nil, err
- }
- return out, nil
-}
-
-func buildMatrixDraftEntities(
- tenantID, ownerUID, personaID, missionID string,
- drafts []domusecase.CreateRequest,
-) ([]*entity.CopyDraft, error) {
- if len(drafts) == 0 {
- return nil, nil
- }
- batchID := uuid.NewString()
- now := clock.NowUnixNano()
- items := make([]*entity.CopyDraft, 0, len(drafts))
- for idx, draftReq := range drafts {
- text := threadspost.FormatDraftText(draftReq.Text)
- if text == "" {
- continue
- }
- sortOrder := draftReq.SortOrder
- if sortOrder == 0 {
- sortOrder = idx + 1
- }
- items = append(items, &entity.CopyDraft{
- ID: uuid.NewString(),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- CopyMissionID: missionID,
- ScanPostID: strings.TrimSpace(draftReq.ScanPostID),
- DraftType: entity.DraftTypeMatrix,
- MatrixBatchID: batchID,
- SortOrder: sortOrder,
- Text: text,
- TopicTag: normalizeTopicTag(draftReq.TopicTag),
- Angle: strings.TrimSpace(draftReq.Angle),
- Hook: strings.TrimSpace(draftReq.Hook),
- Rationale: strings.TrimSpace(draftReq.Rationale),
- ReferenceNotes: strings.TrimSpace(draftReq.ReferenceNotes),
- Sources: draftReq.Sources,
- Status: "pending",
- CreateAt: now,
- })
- }
- if len(items) == 0 {
- return nil, app.For(code.Persona).InputMissingRequired("draft text is required")
- }
- return items, nil
-}
-
-func summariesFromEntities(items []*entity.CopyDraft) []domusecase.CopyDraftSummary {
- out := make([]domusecase.CopyDraftSummary, 0, len(items))
- for _, item := range items {
- if item == nil {
- continue
- }
- out = append(out, toSummary(*item))
- }
- return out
-}
-
-func (u *copyDraftUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, draftID string) error {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return err
- }
- draftID = strings.TrimSpace(draftID)
- if draftID == "" {
- return app.For(code.Persona).InputMissingRequired("draft_id is required")
- }
- return u.repo.Delete(ctx, tenantID, ownerUID, personaID, draftID)
-}
-
-func (u *copyDraftUseCase) DeleteMissionMatrixDrafts(
- ctx context.Context,
- req domusecase.DeleteMissionMatrixDraftsRequest,
-) (int, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return 0, err
- }
- missionID := strings.TrimSpace(req.MissionID)
- if missionID == "" {
- return 0, app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- count, err := u.repo.DeleteMany(
- ctx,
- req.TenantID,
- req.OwnerUID,
- req.PersonaID,
- missionID,
- entity.DraftTypeMatrix,
- req.DraftIDs,
- )
- if err != nil {
- return 0, err
- }
- if len(req.DraftIDs) > 0 && count == 0 {
- return 0, app.For(code.Persona).ResNotFound("找不到要刪除的矩陣草稿")
- }
- return int(count), nil
-}
-
-func (u *copyDraftUseCase) ClearByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return err
- }
- missionID = strings.TrimSpace(missionID)
- if missionID == "" {
- return app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- return u.repo.DeleteByMission(ctx, tenantID, ownerUID, personaID, missionID)
-}
-
-func (u *copyDraftUseCase) ListByMission(ctx context.Context, tenantID, ownerUID, personaID, missionID string, limit int) ([]domusecase.CopyDraftSummary, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByMission(ctx, tenantID, ownerUID, personaID, missionID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.CopyDraftSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func (u *copyDraftUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string, limit int) ([]domusecase.CopyDraftSummary, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- items, err := u.repo.List(ctx, tenantID, ownerUID, personaID, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.CopyDraftSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func (u *copyDraftUseCase) PruneIdleDrafts(ctx context.Context, tenantID, ownerUID, personaID string, keep int) (int64, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return 0, err
- }
- if keep <= 0 {
- keep = defaultIdleDraftKeep
- }
- if keep > 200 {
- keep = 200
- }
- return u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, keep)
-}
-
-func (u *copyDraftUseCase) pruneIdleDraftsBestEffort(ctx context.Context, tenantID, ownerUID, personaID string) {
- _, _ = u.repo.PruneOldestPendingBeyond(ctx, tenantID, ownerUID, personaID, defaultIdleDraftKeep)
-}
-
-func (u *copyDraftUseCase) ListInbox(ctx context.Context, req domusecase.InboxListRequest) (*domusecase.InboxListResult, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- result, err := u.repo.ListInbox(ctx, domrepo.InboxFilter{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: req.PersonaID,
- Status: req.Status,
- RangeStart: req.RangeStart,
- RangeEnd: req.RangeEnd,
- Page: req.Page,
- PageSize: req.PageSize,
- })
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.CopyDraftSummary, 0, len(result.Items))
- for _, item := range result.Items {
- out = append(out, toSummary(item))
- }
- return &domusecase.InboxListResult{Items: out, Total: result.Total}, nil
-}
-
-func requireActor(tenantID, ownerUID, personaID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(personaID) == "" {
- return app.For(code.Persona).InputMissingRequired("persona_id is required")
- }
- return nil
-}
-
-func clampScore(score int) int {
- if score < 0 {
- return 0
- }
- if score > 100 {
- return 100
- }
- return score
-}
-
-func toSummary(item entity.CopyDraft) domusecase.CopyDraftSummary {
- return domusecase.CopyDraftSummary{
- ID: item.ID,
- PersonaID: item.PersonaID,
- ContentPlanID: item.ContentPlanID,
- CopyMissionID: item.CopyMissionID,
- ScanPostID: item.ScanPostID,
- FormulaID: item.FormulaID,
- DraftType: item.DraftType,
- SortOrder: item.SortOrder,
- Text: item.Text,
- TopicTag: item.TopicTag,
- Angle: item.Angle,
- Hook: item.Hook,
- Rationale: item.Rationale,
- ReferenceNotes: item.ReferenceNotes,
- Sources: item.Sources,
- AiScore: item.AiScore,
- FormulaScore: item.FormulaScore,
- BrandFitScore: item.BrandFitScore,
- RiskScore: item.RiskScore,
- SimilarityScore: item.SimilarityScore,
- EngagementPotential: item.EngagementPotential,
- FreshnessScore: item.FreshnessScore,
- ReviewSuggestion: item.ReviewSuggestion,
- Status: item.Status,
- PublishQueueID: item.PublishQueueID,
- PublishedMediaID: item.PublishedMediaID,
- PublishedPermalink: item.PublishedPermalink,
- PublishedAt: item.PublishedAt,
- CreateAt: item.CreateAt,
- }
-}
-
-func normalizeTopicTag(value string) string {
- value = strings.TrimSpace(value)
- value = strings.TrimPrefix(value, "#")
- value = strings.TrimSpace(value)
- if len([]rune(value)) > 40 {
- runes := []rune(value)
- value = string(runes[:40])
- }
- return value
-}
diff --git a/old/backend/internal/model/copy_mission/domain/entity/mission.go b/old/backend/internal/model/copy_mission/domain/entity/mission.go
deleted file mode 100644
index 5f41c2c..0000000
--- a/old/backend/internal/model/copy_mission/domain/entity/mission.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package entity
-
-const CollectionName = "copy_missions"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusMapped Status = "mapped"
- StatusScanned Status = "scanned"
- StatusDrafted Status = "drafted"
- StatusArchived Status = "archived"
- StatusDeleted Status = "deleted"
-)
-
-type SuggestedTag struct {
- Tag string `bson:"tag" json:"tag"`
- Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
- SearchIntent string `bson:"search_intent,omitempty" json:"search_intent,omitempty"`
- SearchType string `bson:"search_type,omitempty" json:"search_type,omitempty"`
-}
-
-const (
- SimilarAccountStatusRecommended = "recommended"
- SimilarAccountStatusPinned = "pinned"
- SimilarAccountStatusExcluded = "excluded"
- SimilarAccountStatusPromoted = "promoted"
-)
-
-const (
- AudienceSampleStatusPinned = "pinned"
- AudienceSampleStatusExcluded = "excluded"
-)
-
-type SimilarAccount struct {
- Username string `bson:"username" json:"username"`
- Reason string `bson:"reason,omitempty" json:"reason,omitempty"`
- Source string `bson:"source,omitempty" json:"source,omitempty"`
- MatchedSource []string `bson:"matched_source,omitempty" json:"matched_source,omitempty"`
- Confidence string `bson:"confidence,omitempty" json:"confidence,omitempty"`
- Status string `bson:"status,omitempty" json:"status,omitempty"`
- TopicRelevance float64 `bson:"topic_relevance,omitempty" json:"topic_relevance,omitempty"`
- LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
- ProfileURL string `bson:"profile_url,omitempty" json:"profile_url,omitempty"`
- AuthorVerified bool `bson:"author_verified,omitempty" json:"author_verified,omitempty"`
- FollowerCount int `bson:"follower_count,omitempty" json:"follower_count,omitempty"`
- EngagementScore int `bson:"engagement_score,omitempty" json:"engagement_score,omitempty"`
- LikeCount int `bson:"like_count,omitempty" json:"like_count,omitempty"`
- ReplyCount int `bson:"reply_count,omitempty" json:"reply_count,omitempty"`
- PostCount int `bson:"post_count,omitempty" json:"post_count,omitempty"`
-}
-
-type ResearchItem struct {
- Title string `bson:"title,omitempty" json:"title,omitempty"`
- URL string `bson:"url,omitempty" json:"url,omitempty"`
- Snippet string `bson:"snippet,omitempty" json:"snippet,omitempty"`
- Query string `bson:"query,omitempty" json:"query,omitempty"`
-}
-
-type AudienceSample struct {
- Username string `bson:"username" json:"username"`
- SamplePostID string `bson:"sample_post_id,omitempty" json:"sample_post_id,omitempty"`
- SampleText string `bson:"sample_text,omitempty" json:"sample_text,omitempty"`
- ReplyLikeCount int `bson:"reply_like_count,omitempty" json:"reply_like_count,omitempty"`
- Appearances int `bson:"appearances,omitempty" json:"appearances,omitempty"`
- FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
- LastSeenAt int64 `bson:"last_seen_at,omitempty" json:"last_seen_at,omitempty"`
- Status string `bson:"status,omitempty" json:"status,omitempty"`
-}
-
-type ResearchMap struct {
- AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"`
- ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"`
- Questions []string `bson:"questions,omitempty" json:"questions,omitempty"`
- Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"`
- Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"`
- KnowledgeItems []string `bson:"knowledge_items,omitempty" json:"knowledge_items,omitempty"`
- SelectedKnowledgeItems []string `bson:"selected_knowledge_items,omitempty" json:"selected_knowledge_items,omitempty"`
- ResearchItems []ResearchItem `bson:"research_items,omitempty" json:"research_items,omitempty"`
- SuggestedTags []SuggestedTag `bson:"suggested_tags,omitempty" json:"suggested_tags,omitempty"`
- SimilarAccounts []SimilarAccount `bson:"similar_accounts,omitempty" json:"similar_accounts,omitempty"`
- AudienceSamples []AudienceSample `bson:"audience_samples,omitempty" json:"audience_samples,omitempty"`
- BenchmarkNotes string `bson:"benchmark_notes,omitempty" json:"benchmark_notes,omitempty"`
-}
-
-func (m ResearchMap) IsEmpty() bool {
- return m.AudienceSummary == "" &&
- m.ContentGoal == "" &&
- len(m.Questions) == 0 &&
- len(m.KnowledgeItems) == 0 &&
- len(m.SuggestedTags) == 0
-}
-
-type Mission struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- Label string `bson:"label,omitempty"`
- SeedQuery string `bson:"seed_query,omitempty"`
- Brief string `bson:"brief,omitempty"`
- ResearchMap ResearchMap `bson:"research_map,omitempty"`
- SelectedTags []string `bson:"selected_tags,omitempty"`
- LastScanJobID string `bson:"last_scan_job_id,omitempty"`
- Status Status `bson:"status"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/copy_mission/domain/repository/repository.go b/old/backend/internal/model/copy_mission/domain/repository/repository.go
deleted file mode 100644
index 34e8143..0000000
--- a/old/backend/internal/model/copy_mission/domain/repository/repository.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/copy_mission/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, mission *entity.Mission) (*entity.Mission, error)
- FindByID(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*entity.Mission, error)
- ListByPersona(ctx context.Context, tenantID, ownerUID, personaID string) ([]*entity.Mission, error)
- Update(ctx context.Context, tenantID, ownerUID, personaID, missionID string, patch map[string]interface{}) (*entity.Mission, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
-}
diff --git a/old/backend/internal/model/copy_mission/domain/usecase/usecase.go b/old/backend/internal/model/copy_mission/domain/usecase/usecase.go
deleted file mode 100644
index 8b41ec1..0000000
--- a/old/backend/internal/model/copy_mission/domain/usecase/usecase.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/copy_mission/domain/entity"
-)
-
-type ResearchItemSummary struct {
- Title string
- URL string
- Snippet string
- Query string
-}
-
-type SuggestedTagSummary struct {
- Tag string
- Reason string
- SearchIntent string
- SearchType string
-}
-
-type SimilarAccountSummary struct {
- Username string
- Reason string
- Source string
- MatchedSource []string
- Confidence string
- Status string
- TopicRelevance float64
- LastSeenAt int64
- ProfileURL string
- AuthorVerified bool
- FollowerCount int
- EngagementScore int
- LikeCount int
- ReplyCount int
- PostCount int
-}
-
-type AudienceSampleSummary struct {
- Username string
- SamplePostID string
- SampleText string
- ReplyLikeCount int
- Appearances int
- FirstSeenAt int64
- LastSeenAt int64
- Status string
-}
-
-type ResearchMapSummary struct {
- AudienceSummary string
- ContentGoal string
- Questions []string
- Pillars []string
- Exclusions []string
- KnowledgeItems []string
- SelectedKnowledgeItems []string
- ResearchItems []ResearchItemSummary
- SuggestedTags []SuggestedTagSummary
- SimilarAccounts []SimilarAccountSummary
- AudienceSamples []AudienceSampleSummary
- BenchmarkNotes string
-}
-
-type MissionSummary struct {
- ID string
- PersonaID string
- Label string
- SeedQuery string
- Brief string
- ResearchMap ResearchMapSummary
- SelectedTags []string
- LastScanJobID string
- Status string
- CreateAt int64
- UpdateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- Label string
- SeedQuery string
- Brief string
- InitialResearchMap *entity.ResearchMap
- InitialSelectedTags []string
-}
-
-type MissionPatch struct {
- Label *string
- SeedQuery *string
- Brief *string
- AudienceSummary *string
- ContentGoal *string
- QuestionsSet bool
- Questions []string
- PillarsSet bool
- Pillars []string
- ExclusionsSet bool
- Exclusions []string
- KnowledgeItemsSet bool
- KnowledgeItems []string
- SelectedKnowledgeItemsSet bool
- SelectedKnowledgeItems []string
- BenchmarkNotes *string
- SelectedTagsSet bool
- SelectedTags []string
- ResearchMap *entity.ResearchMap
- LastScanJobID *string
- Status *entity.Status
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- MissionID string
- Patch MissionPatch
-}
-
-type ListResult struct {
- List []MissionSummary
-}
-
-type PatchSimilarAccountRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- MissionID string
- Username string
- Status string
-}
-
-type PatchAudienceSampleRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- MissionID string
- Username string
- Status string
-}
-
-type UseCase interface {
- List(ctx context.Context, tenantID, ownerUID, personaID string) (*ListResult, error)
- Create(ctx context.Context, req CreateRequest) (*MissionSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*MissionSummary, error)
- Update(ctx context.Context, req UpdateRequest) (*MissionSummary, error)
- PatchSimilarAccount(ctx context.Context, req PatchSimilarAccountRequest) (*MissionSummary, error)
- PatchAudienceSample(ctx context.Context, req PatchAudienceSampleRequest) (*MissionSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error
-}
diff --git a/old/backend/internal/model/copy_mission/repository/mongo.go b/old/backend/internal/model/copy_mission/repository/mongo.go
deleted file mode 100644
index c4260e2..0000000
--- a/old/backend/internal/model/copy_mission/repository/mongo.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/copy_mission/domain/entity"
- domrepo "haixun-backend/internal/model/copy_mission/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, mission *entity.Mission) (*entity.Mission, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- mission.CreateAt = now
- mission.UpdateAt = now
- if mission.Status == "" {
- mission.Status = entity.StatusOpen
- }
- _, err := r.collection.InsertOne(ctx, mission)
- if err != nil {
- return nil, err
- }
- return mission, nil
-}
-
-func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*entity.Mission, error) {
- return r.findOne(ctx, bson.M{
- "_id": strings.TrimSpace(missionID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "persona_id": strings.TrimSpace(personaID),
- "status": bson.M{"$ne": entity.StatusDeleted},
- })
-}
-
-func (r *mongoRepository) ListByPersona(ctx context.Context, tenantID, ownerUID, personaID string) ([]*entity.Mission, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "persona_id": strings.TrimSpace(personaID),
- "status": bson.M{"$ne": entity.StatusDeleted},
- },
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Mission
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, personaID, missionID string, patch map[string]interface{}) (*entity.Mission, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return r.FindByID(ctx, tenantID, ownerUID, personaID, missionID)
- }
- patch["update_at"] = clock.NowUnixNano()
- var out entity.Mission
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{
- "_id": strings.TrimSpace(missionID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "persona_id": strings.TrimSpace(personaID),
- "status": bson.M{"$ne": entity.StatusDeleted},
- },
- bson.M{"$set": patch},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("找不到拷貝任務")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
- _, err := r.Update(ctx, tenantID, ownerUID, personaID, missionID, map[string]interface{}{
- "status": entity.StatusDeleted,
- })
- return err
-}
-
-func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Mission, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- var out entity.Mission
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("找不到拷貝任務")
- }
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/copy_mission/usecase/usecase.go b/old/backend/internal/model/copy_mission/usecase/usecase.go
deleted file mode 100644
index 97b3e1b..0000000
--- a/old/backend/internal/model/copy_mission/usecase/usecase.go
+++ /dev/null
@@ -1,404 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/copy_mission/domain/entity"
- domrepo "haixun-backend/internal/model/copy_mission/domain/repository"
- domusecase "haixun-backend/internal/model/copy_mission/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type missionUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &missionUseCase{repo: repo}
-}
-
-func (u *missionUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) (*domusecase.ListResult, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByPersona(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return nil, err
- }
- list := make([]domusecase.MissionSummary, 0, len(items))
- for _, item := range items {
- list = append(list, toSummary(item))
- }
- return &domusecase.ListResult{List: list}, nil
-}
-
-func (u *missionUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.MissionSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- label := strings.TrimSpace(req.Label)
- seedQuery := strings.TrimSpace(req.SeedQuery)
- brief := strings.TrimSpace(req.Brief)
- if label == "" {
- return nil, app.For(code.Persona).InputMissingRequired("label is required")
- }
- if seedQuery == "" {
- return nil, app.For(code.Persona).InputMissingRequired("seed_query is required")
- }
- if brief == "" {
- return nil, app.For(code.Persona).InputMissingRequired("brief is required")
- }
- mission := &entity.Mission{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- PersonaID: strings.TrimSpace(req.PersonaID),
- Label: label,
- SeedQuery: seedQuery,
- Brief: brief,
- Status: entity.StatusOpen,
- }
- if req.InitialResearchMap != nil {
- mission.ResearchMap = *req.InitialResearchMap
- }
- if len(req.InitialSelectedTags) > 0 {
- mission.SelectedTags = cleanTags(req.InitialSelectedTags)
- }
- item, err := u.repo.Create(ctx, mission)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *missionUseCase) Get(ctx context.Context, tenantID, ownerUID, personaID, missionID string) (*domusecase.MissionSummary, error) {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- item, err := u.repo.FindByID(ctx, tenantID, ownerUID, personaID, missionID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *missionUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.MissionSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- patch := map[string]interface{}{}
- if req.Patch.Label != nil {
- label := strings.TrimSpace(*req.Patch.Label)
- if label == "" {
- return nil, app.For(code.Persona).InputMissingRequired("label cannot be empty")
- }
- patch["label"] = label
- }
- if req.Patch.SeedQuery != nil {
- seed := strings.TrimSpace(*req.Patch.SeedQuery)
- if seed == "" {
- return nil, app.For(code.Persona).InputMissingRequired("seed_query cannot be empty")
- }
- patch["seed_query"] = seed
- }
- if req.Patch.Brief != nil {
- brief := strings.TrimSpace(*req.Patch.Brief)
- if brief == "" {
- return nil, app.For(code.Persona).InputMissingRequired("brief cannot be empty")
- }
- patch["brief"] = brief
- }
- if req.Patch.AudienceSummary != nil {
- patch["research_map.audience_summary"] = strings.TrimSpace(*req.Patch.AudienceSummary)
- }
- if req.Patch.ContentGoal != nil {
- patch["research_map.content_goal"] = strings.TrimSpace(*req.Patch.ContentGoal)
- }
- if req.Patch.QuestionsSet {
- patch["research_map.questions"] = cleanStringList(req.Patch.Questions)
- }
- if req.Patch.PillarsSet {
- patch["research_map.pillars"] = cleanStringList(req.Patch.Pillars)
- }
- if req.Patch.ExclusionsSet {
- patch["research_map.exclusions"] = cleanStringList(req.Patch.Exclusions)
- }
- if req.Patch.KnowledgeItemsSet {
- patch["research_map.knowledge_items"] = cleanStringList(req.Patch.KnowledgeItems)
- }
- if req.Patch.SelectedKnowledgeItemsSet {
- patch["research_map.selected_knowledge_items"] = cleanStringList(req.Patch.SelectedKnowledgeItems)
- }
- if req.Patch.BenchmarkNotes != nil {
- patch["research_map.benchmark_notes"] = strings.TrimSpace(*req.Patch.BenchmarkNotes)
- }
- if req.Patch.SelectedTagsSet {
- patch["selected_tags"] = cleanTags(req.Patch.SelectedTags)
- }
- if req.Patch.ResearchMap != nil {
- patch["research_map"] = *req.Patch.ResearchMap
- }
- if req.Patch.LastScanJobID != nil {
- patch["last_scan_job_id"] = strings.TrimSpace(*req.Patch.LastScanJobID)
- }
- if req.Patch.Status != nil {
- patch["status"] = *req.Patch.Status
- }
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, patch)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *missionUseCase) PatchSimilarAccount(ctx context.Context, req domusecase.PatchSimilarAccountRequest) (*domusecase.MissionSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- status, err := validateSimilarAccountStatus(req.Status)
- if err != nil {
- return nil, err
- }
- username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
- if username == "" {
- return nil, app.For(code.Facade).InputMissingRequired("username is required")
- }
- item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID)
- if err != nil {
- return nil, err
- }
- found := false
- accounts := append([]entity.SimilarAccount(nil), item.ResearchMap.SimilarAccounts...)
- for i := range accounts {
- if strings.EqualFold(strings.TrimSpace(accounts[i].Username), username) {
- accounts[i].Status = status
- found = true
- break
- }
- }
- if !found {
- return nil, app.For(code.Facade).ResNotFound("similar account not found")
- }
- updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{
- "research_map.similar_accounts": accounts,
- })
- if err != nil {
- return nil, err
- }
- summary := toSummary(updated)
- return &summary, nil
-}
-
-func (u *missionUseCase) PatchAudienceSample(ctx context.Context, req domusecase.PatchAudienceSampleRequest) (*domusecase.MissionSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- status, err := validateAudienceSampleStatus(req.Status)
- if err != nil {
- return nil, err
- }
- username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@"))
- if username == "" {
- return nil, app.For(code.Facade).InputMissingRequired("username is required")
- }
- item, err := u.repo.FindByID(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID)
- if err != nil {
- return nil, err
- }
- found := false
- samples := append([]entity.AudienceSample(nil), item.ResearchMap.AudienceSamples...)
- for i := range samples {
- if strings.EqualFold(strings.TrimSpace(samples[i].Username), username) {
- samples[i].Status = status
- found = true
- break
- }
- }
- if !found {
- return nil, app.For(code.Facade).ResNotFound("audience sample not found")
- }
- updated, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.MissionID, map[string]interface{}{
- "research_map.audience_samples": samples,
- })
- if err != nil {
- return nil, err
- }
- summary := toSummary(updated)
- return &summary, nil
-}
-
-func (u *missionUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, missionID string) error {
- if err := requireActor(tenantID, ownerUID, personaID); err != nil {
- return err
- }
- return u.repo.Delete(ctx, tenantID, ownerUID, personaID, missionID)
-}
-
-func requireActor(tenantID, ownerUID, personaID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(personaID) == "" {
- return app.For(code.Persona).InputMissingRequired("persona_id is required")
- }
- return nil
-}
-
-func toSummary(item *entity.Mission) domusecase.MissionSummary {
- if item == nil {
- return domusecase.MissionSummary{}
- }
- tags := make([]domusecase.SuggestedTagSummary, 0, len(item.ResearchMap.SuggestedTags))
- for _, tag := range item.ResearchMap.SuggestedTags {
- tags = append(tags, domusecase.SuggestedTagSummary{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- accounts := make([]domusecase.SimilarAccountSummary, 0, len(item.ResearchMap.SimilarAccounts))
- for _, acc := range item.ResearchMap.SimilarAccounts {
- matched := append([]string(nil), acc.MatchedSource...)
- accounts = append(accounts, domusecase.SimilarAccountSummary{
- Username: acc.Username,
- Reason: acc.Reason,
- Source: acc.Source,
- MatchedSource: matched,
- Confidence: acc.Confidence,
- Status: acc.Status,
- TopicRelevance: acc.TopicRelevance,
- LastSeenAt: acc.LastSeenAt,
- ProfileURL: acc.ProfileURL,
- AuthorVerified: acc.AuthorVerified,
- FollowerCount: acc.FollowerCount,
- EngagementScore: acc.EngagementScore,
- LikeCount: acc.LikeCount,
- ReplyCount: acc.ReplyCount,
- PostCount: acc.PostCount,
- })
- }
- samples := make([]domusecase.AudienceSampleSummary, 0, len(item.ResearchMap.AudienceSamples))
- for _, sample := range item.ResearchMap.AudienceSamples {
- samples = append(samples, domusecase.AudienceSampleSummary{
- Username: sample.Username,
- SamplePostID: sample.SamplePostID,
- SampleText: sample.SampleText,
- ReplyLikeCount: sample.ReplyLikeCount,
- Appearances: sample.Appearances,
- FirstSeenAt: sample.FirstSeenAt,
- LastSeenAt: sample.LastSeenAt,
- Status: sample.Status,
- })
- }
- return domusecase.MissionSummary{
- ID: item.ID,
- PersonaID: item.PersonaID,
- Label: item.Label,
- SeedQuery: item.SeedQuery,
- Brief: item.Brief,
- ResearchMap: toMapSummary(item.ResearchMap, tags, accounts, samples),
- SelectedTags: append([]string(nil), item.SelectedTags...),
- LastScanJobID: item.LastScanJobID,
- Status: string(item.Status),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toResearchItemSummaries(items []entity.ResearchItem) []domusecase.ResearchItemSummary {
- out := make([]domusecase.ResearchItemSummary, 0, len(items))
- for _, item := range items {
- out = append(out, domusecase.ResearchItemSummary{
- Title: item.Title,
- URL: item.URL,
- Snippet: item.Snippet,
- Query: item.Query,
- })
- }
- return out
-}
-
-func toMapSummary(m entity.ResearchMap, tags []domusecase.SuggestedTagSummary, accounts []domusecase.SimilarAccountSummary, samples []domusecase.AudienceSampleSummary) domusecase.ResearchMapSummary {
- return domusecase.ResearchMapSummary{
- AudienceSummary: m.AudienceSummary,
- ContentGoal: m.ContentGoal,
- Questions: append([]string(nil), m.Questions...),
- Pillars: append([]string(nil), m.Pillars...),
- Exclusions: append([]string(nil), m.Exclusions...),
- KnowledgeItems: append([]string(nil), m.KnowledgeItems...),
- SelectedKnowledgeItems: append([]string(nil), m.SelectedKnowledgeItems...),
- ResearchItems: toResearchItemSummaries(m.ResearchItems),
- SuggestedTags: tags,
- SimilarAccounts: accounts,
- AudienceSamples: samples,
- BenchmarkNotes: m.BenchmarkNotes,
- }
-}
-
-func validateSimilarAccountStatus(raw string) (string, error) {
- status := strings.TrimSpace(raw)
- if status == "" {
- status = entity.SimilarAccountStatusRecommended
- }
- switch status {
- case entity.SimilarAccountStatusRecommended,
- entity.SimilarAccountStatusPinned,
- entity.SimilarAccountStatusExcluded,
- entity.SimilarAccountStatusPromoted:
- return status, nil
- default:
- return "", app.For(code.Facade).ResInvalidState("similar account status not allowed")
- }
-}
-
-func validateAudienceSampleStatus(raw string) (string, error) {
- status := strings.TrimSpace(raw)
- switch status {
- case "", entity.AudienceSampleStatusPinned, entity.AudienceSampleStatusExcluded:
- return status, nil
- default:
- return "", app.For(code.Facade).ResInvalidState("audience sample status not allowed")
- }
-}
-
-func cleanStringList(items []string) []string {
- out := make([]string, 0, len(items))
- seen := make(map[string]struct{}, len(items))
- for _, item := range items {
- trimmed := strings.TrimSpace(item)
- if trimmed == "" {
- continue
- }
- if _, ok := seen[trimmed]; ok {
- continue
- }
- seen[trimmed] = struct{}{}
- out = append(out, trimmed)
- }
- return out
-}
-
-func cleanTags(tags []string) []string {
- out := []string{}
- seen := map[string]struct{}{}
- for _, tag := range tags {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- continue
- }
- if _, ok := seen[tag]; ok {
- continue
- }
- seen[tag] = struct{}{}
- out = append(out, tag)
- }
- return out
-}
diff --git a/old/backend/internal/model/job/cron/next_run.go b/old/backend/internal/model/job/cron/next_run.go
deleted file mode 100644
index e249337..0000000
--- a/old/backend/internal/model/job/cron/next_run.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package cron
-
-import (
- "fmt"
- "time"
-
- "haixun-backend/internal/library/clock"
-
- "github.com/robfig/cron/v3"
-)
-
-func NextRunAt(cronExpr, timezone string, from time.Time) (int64, error) {
- expr := cronExpr
- parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
- schedule, err := parser.Parse(expr)
- if err != nil {
- return 0, fmt.Errorf("invalid cron expression: %w", err)
- }
-
- loc := time.UTC
- if timezone != "" {
- loaded, loadErr := time.LoadLocation(timezone)
- if loadErr != nil {
- return 0, fmt.Errorf("invalid timezone: %w", loadErr)
- }
- loc = loaded
- }
-
- next := schedule.Next(from.In(loc))
- return clock.UnixNano(next), nil
-}
diff --git a/old/backend/internal/model/job/cron/next_run_test.go b/old/backend/internal/model/job/cron/next_run_test.go
deleted file mode 100644
index 3ce2e26..0000000
--- a/old/backend/internal/model/job/cron/next_run_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package cron
-
-import (
- "testing"
- "time"
-
- "haixun-backend/internal/library/clock"
-)
-
-func TestNextRunAt_HourlyCron(t *testing.T) {
- from := time.Date(2026, 6, 23, 10, 30, 0, 0, time.UTC)
- next, err := NextRunAt("0 * * * *", "UTC", from)
- if err != nil {
- t.Fatalf("NextRunAt() error = %v", err)
- }
- nextTime := clock.FromUnixNano(next)
- if nextTime.Hour() != 11 || nextTime.Minute() != 0 {
- t.Fatalf("next = %v, want 11:00 UTC", nextTime)
- }
-}
-
-func TestNextRunAt_InvalidCron(t *testing.T) {
- _, err := NextRunAt("not-a-cron", "UTC", clock.Now())
- if err == nil {
- t.Fatal("expected invalid cron error")
- }
-}
diff --git a/old/backend/internal/model/job/domain/entity/event.go b/old/backend/internal/model/job/domain/entity/event.go
deleted file mode 100644
index 5c7975e..0000000
--- a/old/backend/internal/model/job/domain/entity/event.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package entity
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-const EventCollectionName = "job_events"
-
-type Event struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- JobID string `bson:"job_id"`
- Type string `bson:"type"`
- From string `bson:"from,omitempty"`
- To string `bson:"to,omitempty"`
- Message string `bson:"message"`
- Metadata map[string]any `bson:"metadata,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
diff --git a/old/backend/internal/model/job/domain/entity/run.go b/old/backend/internal/model/job/domain/entity/run.go
deleted file mode 100644
index e5e05d7..0000000
--- a/old/backend/internal/model/job/domain/entity/run.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package entity
-
-import (
- "haixun-backend/internal/model/job/domain/enum"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-const RunCollectionName = "job_runs"
-
-type StepProgress struct {
- ID string `bson:"id"`
- Status enum.StepStatus `bson:"status"`
- StartedAt *int64 `bson:"started_at,omitempty"`
- EndedAt *int64 `bson:"ended_at,omitempty"`
- Message string `bson:"message,omitempty"`
-}
-
-type RunProgress struct {
- Summary string `bson:"summary"`
- Percentage int `bson:"percentage"`
- Steps []StepProgress `bson:"steps"`
-}
-
-type Run struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- TenantID string `bson:"tenant_id,omitempty"`
- OwnerUID string `bson:"owner_uid,omitempty"`
- TemplateType string `bson:"template_type"`
- TemplateVersion int `bson:"template_version"`
- Scope string `bson:"scope"`
- ScopeID string `bson:"scope_id"`
- Status enum.RunStatus `bson:"status"`
- Phase string `bson:"phase"`
- WorkerType string `bson:"worker_type"`
- Payload map[string]any `bson:"payload"`
- Progress RunProgress `bson:"progress"`
- Result map[string]any `bson:"result,omitempty"`
- Error string `bson:"error,omitempty"`
- Attempt int `bson:"attempt"`
- MaxAttempts int `bson:"max_attempts"`
- DedupeKey string `bson:"dedupe_key,omitempty"`
- LockedBy string `bson:"locked_by,omitempty"`
- LockedUntil *int64 `bson:"locked_until,omitempty"`
- CancelRequestedAt *int64 `bson:"cancel_requested_at,omitempty"`
- CancelReason string `bson:"cancel_reason,omitempty"`
- ScheduledAt *int64 `bson:"scheduled_at,omitempty"`
- StartedAt *int64 `bson:"started_at,omitempty"`
- CompletedAt *int64 `bson:"completed_at,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/job/domain/entity/schedule.go b/old/backend/internal/model/job/domain/entity/schedule.go
deleted file mode 100644
index 3e62b95..0000000
--- a/old/backend/internal/model/job/domain/entity/schedule.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package entity
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-const ScheduleCollectionName = "job_schedules"
-
-type Schedule struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- TemplateType string `bson:"template_type"`
- Scope string `bson:"scope"`
- ScopeID string `bson:"scope_id"`
- Enabled bool `bson:"enabled"`
- Cron string `bson:"cron"`
- Timezone string `bson:"timezone"`
- PayloadTemplate map[string]any `bson:"payload_template"`
- LastRunAt *int64 `bson:"last_run_at,omitempty"`
- NextRunAt int64 `bson:"next_run_at"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/job/domain/entity/template.go b/old/backend/internal/model/job/domain/entity/template.go
deleted file mode 100644
index 5531f95..0000000
--- a/old/backend/internal/model/job/domain/entity/template.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package entity
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-const TemplateCollectionName = "job_templates"
-
-type CancelPolicy struct {
- Supported bool `bson:"supported"`
- Mode string `bson:"mode"`
- GraceSeconds int `bson:"grace_seconds"`
-}
-
-type RetryPolicy struct {
- MaxAttempts int `bson:"max_attempts"`
- BackoffSeconds []int `bson:"backoff_seconds"`
-}
-
-type TemplateStep struct {
- ID string `bson:"id"`
- Name string `bson:"name"`
- WorkerType string `bson:"worker_type"`
- TimeoutSeconds int `bson:"timeout_seconds"`
- Cancelable bool `bson:"cancelable"`
-}
-
-type Template struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- Type string `bson:"type"`
- Version int `bson:"version"`
- Name string `bson:"name"`
- Description string `bson:"description"`
- Enabled bool `bson:"enabled"`
- Repeatable bool `bson:"repeatable"`
- ConcurrencyPolicy string `bson:"concurrency_policy"`
- DedupeKeys []string `bson:"dedupe_keys"`
- TimeoutSeconds int `bson:"timeout_seconds"`
- CancelPolicy CancelPolicy `bson:"cancel_policy"`
- RetryPolicy RetryPolicy `bson:"retry_policy"`
- Steps []TemplateStep `bson:"steps"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/job/domain/enum/status.go b/old/backend/internal/model/job/domain/enum/status.go
deleted file mode 100644
index bc13c2f..0000000
--- a/old/backend/internal/model/job/domain/enum/status.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package enum
-
-type RunStatus string
-
-const (
- RunStatusPending RunStatus = "pending"
- RunStatusQueued RunStatus = "queued"
- RunStatusRunning RunStatus = "running"
- RunStatusWaitingWorker RunStatus = "waiting_worker"
- RunStatusCancelRequested RunStatus = "cancel_requested"
- RunStatusSucceeded RunStatus = "succeeded"
- RunStatusFailed RunStatus = "failed"
- RunStatusCancelled RunStatus = "cancelled"
- RunStatusExpired RunStatus = "expired"
-)
-
-func (s RunStatus) IsTerminal() bool {
- switch s {
- case RunStatusSucceeded, RunStatusFailed, RunStatusCancelled, RunStatusExpired:
- return true
- default:
- return false
- }
-}
-
-func (s RunStatus) IsCancellable() bool {
- switch s {
- case RunStatusPending, RunStatusQueued, RunStatusRunning, RunStatusWaitingWorker:
- return true
- default:
- return false
- }
-}
-
-type StepStatus string
-
-const (
- StepStatusPending StepStatus = "pending"
- StepStatusRunning StepStatus = "running"
- StepStatusSucceeded StepStatus = "succeeded"
- StepStatusFailed StepStatus = "failed"
- StepStatusSkipped StepStatus = "skipped"
- StepStatusCancelled StepStatus = "cancelled"
-)
-
-type ConcurrencyPolicy string
-
-const (
- ConcurrencyRejectSameScope ConcurrencyPolicy = "reject_same_scope"
- ConcurrencyAllowParallel ConcurrencyPolicy = "allow_parallel"
- ConcurrencyReplaceExisting ConcurrencyPolicy = "replace_existing"
-)
-
-type WorkerType string
-
-const (
- WorkerTypeGo WorkerType = "go"
-)
diff --git a/old/backend/internal/model/job/domain/repository/event.go b/old/backend/internal/model/job/domain/repository/event.go
deleted file mode 100644
index ab82c12..0000000
--- a/old/backend/internal/model/job/domain/repository/event.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-type EventRepository interface {
- EnsureIndexes(ctx context.Context) error
- Append(ctx context.Context, event *entity.Event) error
- ListByJobID(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error)
-}
diff --git a/old/backend/internal/model/job/domain/repository/queue.go b/old/backend/internal/model/job/domain/repository/queue.go
deleted file mode 100644
index 8f013ac..0000000
--- a/old/backend/internal/model/job/domain/repository/queue.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package repository
-
-import "context"
-
-type QueueRepository interface {
- Enqueue(ctx context.Context, workerType, jobID string) error
- Dequeue(ctx context.Context, workerType string, timeoutSeconds int) (string, error)
- RemoveJob(ctx context.Context, workerType, jobID string) error
- RemoveOneJob(ctx context.Context, workerType, jobID string) error
- SetCancelSignal(ctx context.Context, jobID, reason string) error
- GetCancelSignal(ctx context.Context, jobID string) (bool, string, error)
- ClearCancelSignal(ctx context.Context, jobID string) error
- TryLock(ctx context.Context, jobID, workerID string, ttlSeconds int) (bool, error)
- RefreshLock(ctx context.Context, jobID, workerID string, ttlSeconds int) error
- ReleaseLock(ctx context.Context, jobID, workerID string) error
- TryAcquireDedupe(ctx context.Context, templateType, dedupeHash, jobID string, ttlSeconds int) (bool, error)
- ReleaseDedupe(ctx context.Context, templateType, dedupeHash string) error
- TrySchedulerLock(ctx context.Context, holder string, ttlSeconds int) (bool, error)
- ReleaseSchedulerLock(ctx context.Context, holder string) error
-}
diff --git a/old/backend/internal/model/job/domain/repository/run.go b/old/backend/internal/model/job/domain/repository/run.go
deleted file mode 100644
index d403dcc..0000000
--- a/old/backend/internal/model/job/domain/repository/run.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-)
-
-type RunListFilter struct {
- Scope string
- ScopeID string
- TenantID string
- OwnerUID string
- Statuses []enum.RunStatus
-}
-
-type RunRepository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, run *entity.Run) (*entity.Run, error)
- Update(ctx context.Context, run *entity.Run) (*entity.Run, error)
- UpdateIfStatus(ctx context.Context, run *entity.Run, allowed []enum.RunStatus) (*entity.Run, error)
- UpdateIfLocked(ctx context.Context, run *entity.Run, workerID string, allowed []enum.RunStatus) (*entity.Run, error)
- FindByID(ctx context.Context, id string) (*entity.Run, error)
- List(ctx context.Context, filter RunListFilter, offset, limit int64) ([]*entity.Run, int64, error)
- FindActiveByScope(ctx context.Context, templateType, scope, scopeID string) ([]*entity.Run, error)
- FindSucceededByDedupeKey(ctx context.Context, templateType, dedupeKey string) (*entity.Run, error)
- FindPendingDue(ctx context.Context, now int64, limit int64) ([]*entity.Run, error)
- FindCancelRequestedBefore(ctx context.Context, before int64, limit int64) ([]*entity.Run, error)
- FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error)
- FindQueuedWithLock(ctx context.Context, limit int64) ([]*entity.Run, error)
-}
diff --git a/old/backend/internal/model/job/domain/repository/schedule.go b/old/backend/internal/model/job/domain/repository/schedule.go
deleted file mode 100644
index 16fd000..0000000
--- a/old/backend/internal/model/job/domain/repository/schedule.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-type ScheduleListFilter struct {
- Scope string
- ScopeID string
-}
-
-type ScheduleRepository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, schedule *entity.Schedule) (*entity.Schedule, error)
- Update(ctx context.Context, schedule *entity.Schedule) (*entity.Schedule, error)
- FindByID(ctx context.Context, id string) (*entity.Schedule, error)
- Delete(ctx context.Context, id string) error
- List(ctx context.Context, filter ScheduleListFilter, offset, limit int64) ([]*entity.Schedule, int64, error)
- FindDue(ctx context.Context, now int64, limit int64) ([]*entity.Schedule, error)
-}
diff --git a/old/backend/internal/model/job/domain/repository/template.go b/old/backend/internal/model/job/domain/repository/template.go
deleted file mode 100644
index 6ba253e..0000000
--- a/old/backend/internal/model/job/domain/repository/template.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-type TemplateRepository interface {
- EnsureIndexes(ctx context.Context) error
- List(ctx context.Context) ([]*entity.Template, error)
- FindByType(ctx context.Context, templateType string) (*entity.Template, error)
- Upsert(ctx context.Context, template *entity.Template) (*entity.Template, error)
-}
diff --git a/old/backend/internal/model/job/domain/usecase/job.go b/old/backend/internal/model/job/domain/usecase/job.go
deleted file mode 100644
index 9efd7db..0000000
--- a/old/backend/internal/model/job/domain/usecase/job.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/repository"
-)
-
-type CreateRunRequest struct {
- TemplateType string
- Scope string
- ScopeID string
- TenantID string
- OwnerUID string
- Payload map[string]any
- ScheduledAt *int64
-}
-
-type CancelRunRequest struct {
- JobID string
- Reason string
-}
-
-type ClaimNextRequest struct {
- WorkerType string
- WorkerID string
-}
-
-type UpdateProgressRequest struct {
- JobID string
- WorkerID string
- Phase string
- Summary string
- Percentage int
- Steps []entity.StepProgress
-}
-
-type CompleteRunRequest struct {
- JobID string
- WorkerID string
- Result map[string]any
-}
-
-type FailRunRequest struct {
- JobID string
- WorkerID string
- Error string
- Phase string
-}
-
-type AcknowledgeCancelRequest struct {
- JobID string
- WorkerID string
-}
-
-type UpsertTemplateRequest struct {
- Type string
- Version int
- Name string
- Description string
- Enabled bool
- Repeatable bool
- ConcurrencyPolicy string
- DedupeKeys []string
- TimeoutSeconds int
- CancelPolicy entity.CancelPolicy
- RetryPolicy entity.RetryPolicy
- Steps []entity.TemplateStep
-}
-
-type CreateScheduleRequest struct {
- TemplateType string
- Scope string
- ScopeID string
- Cron string
- Timezone string
- PayloadTemplate map[string]any
- Enabled bool
-}
-
-type UpdateScheduleRequest struct {
- ID string
- Cron string
- Timezone string
- PayloadTemplate map[string]any
- Enabled *bool
-}
-
-type UseCase interface {
- ListTemplates(ctx context.Context) ([]*entity.Template, error)
- GetTemplate(ctx context.Context, templateType string) (*entity.Template, error)
- UpsertTemplate(ctx context.Context, req UpsertTemplateRequest) (*entity.Template, error)
- EnsureDemoTemplate(ctx context.Context) error
- EnsureStyle8DTemplate(ctx context.Context) error
- EnsureExpandGraphTemplate(ctx context.Context) error
- EnsurePlacementScanTemplate(ctx context.Context) error
- EnsureScanViralTemplate(ctx context.Context) error
- EnsureGenerateOutreachDraftTemplate(ctx context.Context) error
- EnsureGenerateTopicMatrixTemplate(ctx context.Context) error
- EnsureGenerateFormulaDraftTemplate(ctx context.Context) error
- EnsureGenerateRewriteDraftTemplate(ctx context.Context) error
- EnsureRefreshThreadsTokenTemplate(ctx context.Context) error
- EnsurePublishAnalyticsTemplate(ctx context.Context) error
-
- CreateRun(ctx context.Context, req CreateRunRequest) (*entity.Run, error)
- GetRun(ctx context.Context, jobID string) (*entity.Run, error)
- ListRuns(ctx context.Context, filter repository.RunListFilter, page, pageSize int64) ([]*entity.Run, int64, int64, int64, int64, error)
- RequestCancel(ctx context.Context, req CancelRunRequest) (*entity.Run, error)
- RetryRun(ctx context.Context, jobID string) (*entity.Run, error)
- ListJobEvents(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error)
-
- ClaimNext(ctx context.Context, req ClaimNextRequest) (*entity.Run, error)
- RefreshRunLock(ctx context.Context, jobID, workerID string, ttlSeconds int) error
- IsCancelRequested(ctx context.Context, jobID string) (bool, error)
- AcknowledgeCancel(ctx context.Context, req AcknowledgeCancelRequest) (*entity.Run, error)
- UpdateProgress(ctx context.Context, req UpdateProgressRequest) (*entity.Run, error)
- CompleteRun(ctx context.Context, req CompleteRunRequest) (*entity.Run, error)
- FailRun(ctx context.Context, req FailRunRequest) (*entity.Run, error)
-
- ListSchedules(ctx context.Context, scope, scopeID string, page, pageSize int64) ([]*entity.Schedule, int64, int64, int64, int64, error)
- GetSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error)
- CreateSchedule(ctx context.Context, req CreateScheduleRequest) (*entity.Schedule, error)
- UpdateSchedule(ctx context.Context, req UpdateScheduleRequest) (*entity.Schedule, error)
- EnableSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error)
- DisableSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error)
- DeleteSchedule(ctx context.Context, scheduleID string) error
-
- RunSchedulerTick(ctx context.Context, holder string) (int, error)
- RunMaintenance(ctx context.Context) (MaintenanceResult, error)
-}
-
-type MaintenanceResult struct {
- EnqueuedPending int
- ReapedCancelGrace int
- ReapedExpiredLocks int
- RepairedStuckClaims int
- ReapedOrphanedLocks int
-}
diff --git a/old/backend/internal/model/job/repository/mongo_event.go b/old/backend/internal/model/job/repository/mongo_event.go
deleted file mode 100644
index 43acf32..0000000
--- a/old/backend/internal/model/job/repository/mongo_event.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- domrepo "haixun-backend/internal/model/job/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoEventRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoEventRepository(db *mongo.Database) domrepo.EventRepository {
- if db == nil {
- return &mongoEventRepository{}
- }
- return &mongoEventRepository{collection: db.Collection(entity.EventCollectionName)}
-}
-
-func (r *mongoEventRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "job_id", Value: 1}, {Key: "create_at", Value: -1}},
- })
- return err
-}
-
-func (r *mongoEventRepository) Append(ctx context.Context, event *entity.Event) error {
- if r.collection == nil {
- return app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if event.CreateAt == 0 {
- event.CreateAt = clock.NowUnixNano()
- }
- _, err := r.collection.InsertOne(ctx, event)
- return err
-}
-
-func (r *mongoEventRepository) ListByJobID(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{"job_id": jobID},
- options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(limit),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
-
- var items []*entity.Event
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
diff --git a/old/backend/internal/model/job/repository/mongo_run.go b/old/backend/internal/model/job/repository/mongo_run.go
deleted file mode 100644
index ce582d1..0000000
--- a/old/backend/internal/model/job/repository/mongo_run.go
+++ /dev/null
@@ -1,379 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domrepo "haixun-backend/internal/model/job/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/bson/primitive"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRunRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRunRepository(db *mongo.Database) domrepo.RunRepository {
- if db == nil {
- return &mongoRunRepository{}
- }
- return &mongoRunRepository{collection: db.Collection(entity.RunCollectionName)}
-}
-
-func (r *mongoRunRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- models := []mongo.IndexModel{
- {Keys: bson.D{{Key: "template_type", Value: 1}, {Key: "scope", Value: 1}, {Key: "scope_id", Value: 1}, {Key: "status", Value: 1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "create_at", Value: -1}}},
- {Keys: bson.D{{Key: "payload.owner_uid", Value: 1}, {Key: "create_at", Value: -1}}},
- {Keys: bson.D{{Key: "create_at", Value: -1}}},
- {Keys: bson.D{{Key: "dedupe_key", Value: 1}}},
- }
- _, err := r.collection.Indexes().CreateMany(ctx, models)
- return err
-}
-
-func (r *mongoRunRepository) Create(ctx context.Context, run *entity.Run) (*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- run.CreateAt = now
- run.UpdateAt = now
- res, err := r.collection.InsertOne(ctx, run)
- if err != nil {
- return nil, err
- }
- run.ID = res.InsertedID.(primitive.ObjectID)
- return run, nil
-}
-
-func (r *mongoRunRepository) Update(ctx context.Context, run *entity.Run) (*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- run.UpdateAt = clock.NowUnixNano()
- filter := bson.M{"_id": run.ID}
- update, err := runSet(run)
- if err != nil {
- return nil, err
- }
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.Run
- err = r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Job).ResNotFound("job run not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRunRepository) UpdateIfStatus(ctx context.Context, run *entity.Run, allowed []enum.RunStatus) (*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if len(allowed) == 0 {
- return nil, app.For(code.Job).ResInvalidState("allowed statuses are required")
- }
- run.UpdateAt = clock.NowUnixNano()
- filter := bson.M{"_id": run.ID, "status": bson.M{"$in": allowed}}
- return r.updateWithFilter(ctx, filter, run)
-}
-
-func (r *mongoRunRepository) UpdateIfLocked(ctx context.Context, run *entity.Run, workerID string, allowed []enum.RunStatus) (*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if workerID == "" {
- return nil, app.For(code.Job).InputMissingRequired("worker id is required")
- }
- if len(allowed) == 0 {
- return nil, app.For(code.Job).ResInvalidState("allowed statuses are required")
- }
- run.UpdateAt = clock.NowUnixNano()
- filter := bson.M{
- "_id": run.ID,
- "locked_by": workerID,
- "status": bson.M{"$in": allowed},
- }
- return r.updateWithFilter(ctx, filter, run)
-}
-
-func (r *mongoRunRepository) updateWithFilter(ctx context.Context, filter bson.M, run *entity.Run) (*entity.Run, error) {
- update, err := runSet(run)
- if err != nil {
- return nil, err
- }
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.Run
- err = r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Job).ResInvalidState("job state changed; update rejected")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func runSet(run *entity.Run) (bson.M, error) {
- raw, err := bson.Marshal(run)
- if err != nil {
- return nil, err
- }
- set := bson.M{}
- if err := bson.Unmarshal(raw, &set); err != nil {
- return nil, err
- }
- delete(set, "_id")
- return bson.M{"$set": set}, nil
-}
-
-func (r *mongoRunRepository) FindByID(ctx context.Context, id string) (*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- objectID, err := primitive.ObjectIDFromHex(id)
- if err != nil {
- return nil, app.For(code.Job).InputInvalidFormat("invalid job id")
- }
- var run entity.Run
- err = r.collection.FindOne(ctx, bson.M{"_id": objectID}).Decode(&run)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Job).ResNotFound("job run not found")
- }
- if err != nil {
- return nil, err
- }
- return &run, nil
-}
-
-func (r *mongoRunRepository) List(ctx context.Context, filter domrepo.RunListFilter, offset, limit int64) ([]*entity.Run, int64, error) {
- if r.collection == nil {
- return nil, 0, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- query := buildRunListQuery(filter)
-
- total, err := r.collection.CountDocuments(ctx, query)
- if err != nil {
- return nil, 0, err
- }
- cursor, err := r.collection.Find(
- ctx,
- query,
- options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetSkip(offset).SetLimit(limit),
- )
- if err != nil {
- return nil, 0, err
- }
- defer cursor.Close(ctx)
-
- var items []*entity.Run
- if err := cursor.All(ctx, &items); err != nil {
- return nil, 0, err
- }
- return items, total, nil
-}
-
-func (r *mongoRunRepository) FindActiveByScope(ctx context.Context, templateType, scope, scopeID string) ([]*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- active := []enum.RunStatus{
- enum.RunStatusPending,
- enum.RunStatusQueued,
- enum.RunStatusRunning,
- enum.RunStatusWaitingWorker,
- enum.RunStatusCancelRequested,
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "template_type": templateType,
- "scope": scope,
- "scope_id": scopeID,
- "status": bson.M{"$in": active},
- })
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
-
- var items []*entity.Run
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRunRepository) FindSucceededByDedupeKey(ctx context.Context, templateType, dedupeKey string) (*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- var run entity.Run
- err := r.collection.FindOne(ctx, bson.M{
- "template_type": templateType,
- "dedupe_key": dedupeKey,
- "status": enum.RunStatusSucceeded,
- }).Decode(&run)
- if err == mongo.ErrNoDocuments {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return &run, nil
-}
-
-func (r *mongoRunRepository) FindPendingDue(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "status": enum.RunStatusPending,
- "$or": []bson.M{
- {"scheduled_at": bson.M{"$lte": now, "$ne": nil}},
- {"scheduled_at": nil},
- {"scheduled_at": bson.M{"$exists": false}},
- },
- }, options.Find().SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).SetLimit(limit))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Run
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRunRepository) FindCancelRequestedBefore(ctx context.Context, before int64, limit int64) ([]*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "status": enum.RunStatusCancelRequested,
- "cancel_requested_at": bson.M{
- "$lte": before,
- "$ne": nil,
- },
- }, options.Find().SetSort(bson.D{{Key: "cancel_requested_at", Value: 1}}).SetLimit(limit))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Run
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRunRepository) FindQueuedWithLock(ctx context.Context, limit int64) ([]*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "status": bson.M{"$in": []enum.RunStatus{enum.RunStatusPending, enum.RunStatusQueued}},
- "locked_by": bson.M{
- "$exists": true,
- "$ne": "",
- },
- }, options.Find().SetSort(bson.D{{Key: "update_at", Value: 1}}).SetLimit(limit))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Run
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRunRepository) FindRunningTimedOut(ctx context.Context, now int64, limit int64) ([]*entity.Run, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "status": bson.M{"$in": []enum.RunStatus{enum.RunStatusRunning, enum.RunStatusWaitingWorker}},
- "locked_until": bson.M{
- "$lte": now,
- "$ne": nil,
- },
- }, options.Find().SetSort(bson.D{{Key: "locked_until", Value: 1}}).SetLimit(limit))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Run
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func buildRunListQuery(filter domrepo.RunListFilter) bson.M {
- clauses := make([]bson.M, 0, 4)
- if filter.Scope != "" {
- clauses = append(clauses, bson.M{"scope": filter.Scope})
- }
- if filter.ScopeID != "" {
- clauses = append(clauses, bson.M{"scope_id": filter.ScopeID})
- }
- if filter.OwnerUID != "" {
- clauses = append(clauses, bson.M{"$or": []bson.M{
- {"owner_uid": filter.OwnerUID},
- {"payload.owner_uid": filter.OwnerUID},
- {"scope": "user", "scope_id": filter.OwnerUID},
- }})
- }
- if filter.TenantID != "" {
- clauses = append(clauses, bson.M{"$or": []bson.M{
- {"tenant_id": filter.TenantID},
- {"payload.tenant_id": filter.TenantID},
- {"$and": []bson.M{
- {"$or": []bson.M{
- {"tenant_id": bson.M{"$exists": false}},
- {"tenant_id": ""},
- }},
- {"$or": []bson.M{
- {"payload.tenant_id": bson.M{"$exists": false}},
- {"payload.tenant_id": ""},
- }},
- }},
- }})
- }
- if len(filter.Statuses) > 0 {
- clauses = append(clauses, bson.M{"status": bson.M{"$in": filter.Statuses}})
- }
- if len(clauses) == 0 {
- return bson.M{}
- }
- if len(clauses) == 1 {
- return clauses[0]
- }
- return bson.M{"$and": clauses}
-}
diff --git a/old/backend/internal/model/job/repository/mongo_schedule.go b/old/backend/internal/model/job/repository/mongo_schedule.go
deleted file mode 100644
index 26b5575..0000000
--- a/old/backend/internal/model/job/repository/mongo_schedule.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- domrepo "haixun-backend/internal/model/job/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/bson/primitive"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoScheduleRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoScheduleRepository(db *mongo.Database) domrepo.ScheduleRepository {
- if db == nil {
- return &mongoScheduleRepository{}
- }
- return &mongoScheduleRepository{collection: db.Collection(entity.ScheduleCollectionName)}
-}
-
-func (r *mongoScheduleRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- models := []mongo.IndexModel{
- {Keys: bson.D{{Key: "enabled", Value: 1}, {Key: "next_run_at", Value: 1}}},
- {Keys: bson.D{{Key: "template_type", Value: 1}, {Key: "scope", Value: 1}, {Key: "scope_id", Value: 1}}},
- }
- _, err := r.collection.Indexes().CreateMany(ctx, models)
- return err
-}
-
-func (r *mongoScheduleRepository) Create(ctx context.Context, schedule *entity.Schedule) (*entity.Schedule, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- schedule.CreateAt = now
- schedule.UpdateAt = now
- res, err := r.collection.InsertOne(ctx, schedule)
- if err != nil {
- return nil, err
- }
- schedule.ID = res.InsertedID.(primitive.ObjectID)
- return schedule, nil
-}
-
-func (r *mongoScheduleRepository) Update(ctx context.Context, schedule *entity.Schedule) (*entity.Schedule, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- schedule.UpdateAt = clock.NowUnixNano()
- filter := bson.M{"_id": schedule.ID}
- update := bson.M{"$set": schedule}
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.Schedule
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Job).ResNotFound("job schedule not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoScheduleRepository) Delete(ctx context.Context, id string) error {
- if r.collection == nil {
- return app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- objectID, err := primitive.ObjectIDFromHex(id)
- if err != nil {
- return app.For(code.Job).InputInvalidFormat("invalid schedule id")
- }
- res, err := r.collection.DeleteOne(ctx, bson.M{"_id": objectID})
- if err != nil {
- return err
- }
- if res.DeletedCount == 0 {
- return app.For(code.Job).ResNotFound("job schedule not found")
- }
- return nil
-}
-
-func (r *mongoScheduleRepository) FindByID(ctx context.Context, id string) (*entity.Schedule, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- objectID, err := primitive.ObjectIDFromHex(id)
- if err != nil {
- return nil, app.For(code.Job).InputInvalidFormat("invalid schedule id")
- }
- var schedule entity.Schedule
- err = r.collection.FindOne(ctx, bson.M{"_id": objectID}).Decode(&schedule)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Job).ResNotFound("job schedule not found")
- }
- if err != nil {
- return nil, err
- }
- return &schedule, nil
-}
-
-func (r *mongoScheduleRepository) List(ctx context.Context, filter domrepo.ScheduleListFilter, offset, limit int64) ([]*entity.Schedule, int64, error) {
- if r.collection == nil {
- return nil, 0, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- query := bson.M{}
- if filter.Scope != "" {
- query["scope"] = filter.Scope
- }
- if filter.ScopeID != "" {
- query["scope_id"] = filter.ScopeID
- }
- total, err := r.collection.CountDocuments(ctx, query)
- if err != nil {
- return nil, 0, err
- }
- cursor, err := r.collection.Find(
- ctx,
- query,
- options.Find().SetSort(bson.D{{Key: "next_run_at", Value: 1}}).SetSkip(offset).SetLimit(limit),
- )
- if err != nil {
- return nil, 0, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Schedule
- if err := cursor.All(ctx, &items); err != nil {
- return nil, 0, err
- }
- return items, total, nil
-}
-
-func (r *mongoScheduleRepository) FindDue(ctx context.Context, now int64, limit int64) ([]*entity.Schedule, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "enabled": true,
- "next_run_at": bson.M{"$lte": now},
- }, options.Find().SetSort(bson.D{{Key: "next_run_at", Value: 1}}).SetLimit(limit))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Schedule
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
diff --git a/old/backend/internal/model/job/repository/mongo_template.go b/old/backend/internal/model/job/repository/mongo_template.go
deleted file mode 100644
index d84508b..0000000
--- a/old/backend/internal/model/job/repository/mongo_template.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- domrepo "haixun-backend/internal/model/job/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoTemplateRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoTemplateRepository(db *mongo.Database) domrepo.TemplateRepository {
- if db == nil {
- return &mongoTemplateRepository{}
- }
- return &mongoTemplateRepository{collection: db.Collection(entity.TemplateCollectionName)}
-}
-
-func (r *mongoTemplateRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "type", Value: 1}},
- Options: options.Index().SetUnique(true),
- })
- return err
-}
-
-func (r *mongoTemplateRepository) List(ctx context.Context) ([]*entity.Template, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "type", Value: 1}}))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
-
- var items []*entity.Template
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoTemplateRepository) FindByType(ctx context.Context, templateType string) (*entity.Template, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- var item entity.Template
- err := r.collection.FindOne(ctx, bson.M{"type": templateType}).Decode(&item)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Job).ResNotFound("job template not found")
- }
- if err != nil {
- return nil, err
- }
- return &item, nil
-}
-
-func (r *mongoTemplateRepository) Upsert(ctx context.Context, template *entity.Template) (*entity.Template, error) {
- if r.collection == nil {
- return nil, app.For(code.Job).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- if template.CreateAt == 0 {
- template.CreateAt = now
- }
- template.UpdateAt = now
-
- filter := bson.M{"type": template.Type}
- update := bson.M{"$set": template}
- opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
- var out entity.Template
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/job/repository/redis_queue.go b/old/backend/internal/model/job/repository/redis_queue.go
deleted file mode 100644
index c792427..0000000
--- a/old/backend/internal/model/job/repository/redis_queue.go
+++ /dev/null
@@ -1,227 +0,0 @@
-package repository
-
-import (
- "context"
- "fmt"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- domrepo "haixun-backend/internal/model/job/domain/repository"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-const (
- queueKeyPrefix = "jobs:queue:"
- lockKeyPrefix = "jobs:lock:"
- cancelKeyPrefix = "jobs:cancel:"
- dedupeKeyPrefix = "jobs:dedupe:"
- schedulerLockKey = "jobs:scheduler:lock"
-)
-
-type redisQueueRepository struct {
- client *goredis.Client
-}
-
-func NewRedisQueueRepository(client *goredis.Client) domrepo.QueueRepository {
- return &redisQueueRepository{client: client}
-}
-
-func (r *redisQueueRepository) requireRedis() error {
- if r.client == nil {
- return app.For(code.Job).DBUnavailable("Redis is not configured")
- }
- return nil
-}
-
-func queueKey(workerType string) string {
- return queueKeyPrefix + workerType
-}
-
-func lockKey(jobID string) string {
- return lockKeyPrefix + jobID
-}
-
-func cancelKey(jobID string) string {
- return cancelKeyPrefix + jobID
-}
-
-func (r *redisQueueRepository) Enqueue(ctx context.Context, workerType, jobID string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- return r.client.LPush(ctx, queueKey(workerType), jobID).Err()
-}
-
-func (r *redisQueueRepository) Dequeue(ctx context.Context, workerType string, timeoutSeconds int) (string, error) {
- if err := r.requireRedis(); err != nil {
- return "", err
- }
- if timeoutSeconds <= 0 {
- timeoutSeconds = 2
- }
- result, err := r.client.BRPop(ctx, time.Duration(timeoutSeconds)*time.Second, queueKey(workerType)).Result()
- if err == goredis.Nil {
- return "", nil
- }
- if err != nil {
- return "", err
- }
- if len(result) < 2 {
- return "", nil
- }
- return result[1], nil
-}
-
-func (r *redisQueueRepository) RemoveJob(ctx context.Context, workerType, jobID string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- return r.client.LRem(ctx, queueKey(workerType), 0, jobID).Err()
-}
-
-func (r *redisQueueRepository) RemoveOneJob(ctx context.Context, workerType, jobID string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- return r.client.LRem(ctx, queueKey(workerType), 1, jobID).Err()
-}
-
-func (r *redisQueueRepository) SetCancelSignal(ctx context.Context, jobID, reason string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- payload := reason
- if payload == "" {
- payload = "cancel_requested"
- }
- return r.client.Set(ctx, cancelKey(jobID), payload, 24*time.Hour).Err()
-}
-
-func (r *redisQueueRepository) GetCancelSignal(ctx context.Context, jobID string) (bool, string, error) {
- if err := r.requireRedis(); err != nil {
- return false, "", err
- }
- value, err := r.client.Get(ctx, cancelKey(jobID)).Result()
- if err == goredis.Nil {
- return false, "", nil
- }
- if err != nil {
- return false, "", err
- }
- return true, value, nil
-}
-
-func (r *redisQueueRepository) ClearCancelSignal(ctx context.Context, jobID string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- return r.client.Del(ctx, cancelKey(jobID)).Err()
-}
-
-func (r *redisQueueRepository) TryLock(ctx context.Context, jobID, workerID string, ttlSeconds int) (bool, error) {
- if err := r.requireRedis(); err != nil {
- return false, err
- }
- if ttlSeconds <= 0 {
- ttlSeconds = 300
- }
- ok, err := r.client.SetNX(ctx, lockKey(jobID), workerID, time.Duration(ttlSeconds)*time.Second).Result()
- if err != nil {
- return false, err
- }
- return ok, nil
-}
-
-func (r *redisQueueRepository) ReleaseLock(ctx context.Context, jobID, workerID string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- if workerID == "" {
- return app.For(code.Job).InputMissingRequired("worker id is required")
- }
- const script = `
-if redis.call("GET", KEYS[1]) == ARGV[1] then
- return redis.call("DEL", KEYS[1])
-end
-return 0
-`
- return r.client.Eval(ctx, script, []string{lockKey(jobID)}, workerID).Err()
-}
-
-func dedupeRedisKey(templateType, dedupeHash string) string {
- return dedupeKeyPrefix + templateType + ":" + dedupeHash
-}
-
-func (r *redisQueueRepository) TryAcquireDedupe(ctx context.Context, templateType, dedupeHash, jobID string, ttlSeconds int) (bool, error) {
- if err := r.requireRedis(); err != nil {
- return false, err
- }
- if ttlSeconds <= 0 {
- ttlSeconds = 3600
- }
- ok, err := r.client.SetNX(ctx, dedupeRedisKey(templateType, dedupeHash), jobID, time.Duration(ttlSeconds)*time.Second).Result()
- return ok, err
-}
-
-func (r *redisQueueRepository) ReleaseDedupe(ctx context.Context, templateType, dedupeHash string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- return r.client.Del(ctx, dedupeRedisKey(templateType, dedupeHash)).Err()
-}
-
-func (r *redisQueueRepository) TrySchedulerLock(ctx context.Context, holder string, ttlSeconds int) (bool, error) {
- if err := r.requireRedis(); err != nil {
- return false, err
- }
- if ttlSeconds <= 0 {
- ttlSeconds = 60
- }
- ok, err := r.client.SetNX(ctx, schedulerLockKey, holder, time.Duration(ttlSeconds)*time.Second).Result()
- return ok, err
-}
-
-func (r *redisQueueRepository) ReleaseSchedulerLock(ctx context.Context, holder string) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- current, err := r.client.Get(ctx, schedulerLockKey).Result()
- if err == goredis.Nil {
- return nil
- }
- if err != nil {
- return err
- }
- if current != holder {
- return nil
- }
- return r.client.Del(ctx, schedulerLockKey).Err()
-}
-
-func (r *redisQueueRepository) RefreshLock(ctx context.Context, jobID, workerID string, ttlSeconds int) error {
- if err := r.requireRedis(); err != nil {
- return err
- }
- if workerID == "" {
- return app.For(code.Job).InputMissingRequired("worker id is required")
- }
- if ttlSeconds <= 0 {
- ttlSeconds = 300
- }
- const script = `
-if redis.call("GET", KEYS[1]) == ARGV[1] then
- return redis.call("EXPIRE", KEYS[1], ARGV[2])
-end
-return 0
-`
- result, err := r.client.Eval(ctx, script, []string{lockKey(jobID)}, workerID, ttlSeconds).Int()
- if err != nil {
- return err
- }
- if result == 0 {
- return app.For(code.Job).ResInvalidState(fmt.Sprintf("job lock is not held by worker %s", workerID))
- }
- return nil
-}
diff --git a/old/backend/internal/model/job/repository/redis_queue_test.go b/old/backend/internal/model/job/repository/redis_queue_test.go
deleted file mode 100644
index 5449f4e..0000000
--- a/old/backend/internal/model/job/repository/redis_queue_test.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package repository
-
-import (
- "context"
- "testing"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-func TestRedisQueue_DedupeAndSchedulerLock(t *testing.T) {
- ctx := context.Background()
- client := goredis.NewClient(&goredis.Options{Addr: "127.0.0.1:6379"})
- if err := client.Ping(ctx).Err(); err != nil {
- t.Skip("redis not available:", err)
- }
- defer client.Close()
-
- repo := NewRedisQueueRepository(client)
- ok, err := repo.TryAcquireDedupe(ctx, "demo", "hash1", "job-1", 60)
- if err != nil || !ok {
- t.Fatalf("TryAcquireDedupe() = %v, %v", ok, err)
- }
- ok, err = repo.TryAcquireDedupe(ctx, "demo", "hash1", "job-2", 60)
- if err != nil || ok {
- t.Fatalf("duplicate TryAcquireDedupe() = %v, %v", ok, err)
- }
- if err := repo.ReleaseDedupe(ctx, "demo", "hash1"); err != nil {
- t.Fatalf("ReleaseDedupe() error = %v", err)
- }
-
- ok, err = repo.TrySchedulerLock(ctx, "holder-a", 30)
- if err != nil || !ok {
- t.Fatalf("TrySchedulerLock() = %v, %v", ok, err)
- }
- ok, err = repo.TrySchedulerLock(ctx, "holder-b", 30)
- if err != nil || ok {
- t.Fatalf("duplicate TrySchedulerLock() = %v, %v", ok, err)
- }
- _ = repo.ReleaseSchedulerLock(ctx, "holder-a")
-}
-
-func TestRedisQueue_LockOwnerGuards(t *testing.T) {
- ctx := context.Background()
- client := goredis.NewClient(&goredis.Options{Addr: "127.0.0.1:6379"})
- if err := client.Ping(ctx).Err(); err != nil {
- t.Skip("redis not available:", err)
- }
- defer client.Close()
-
- repo := NewRedisQueueRepository(client)
- jobID := "owner-guard-job"
- _ = client.Del(ctx, lockKey(jobID))
-
- ok, err := repo.TryLock(ctx, jobID, "worker-a", 30)
- if err != nil || !ok {
- t.Fatalf("TryLock() = %v, %v", ok, err)
- }
- if err := repo.ReleaseLock(ctx, jobID, "worker-b"); err != nil {
- t.Fatalf("ReleaseLock(worker-b) error = %v", err)
- }
- if value, err := client.Get(ctx, lockKey(jobID)).Result(); err != nil || value != "worker-a" {
- t.Fatalf("lock value after wrong release = %q, %v", value, err)
- }
- if err := repo.RefreshLock(ctx, jobID, "worker-b", 30); err == nil {
- t.Fatal("RefreshLock(worker-b) error = nil, want error")
- }
- if err := repo.ReleaseLock(ctx, jobID, "worker-a"); err != nil {
- t.Fatalf("ReleaseLock(worker-a) error = %v", err)
- }
- _ = client.Del(ctx, lockKey(jobID))
-}
diff --git a/old/backend/internal/model/job/resume/resume.go b/old/backend/internal/model/job/resume/resume.go
deleted file mode 100644
index 413f5fe..0000000
--- a/old/backend/internal/model/job/resume/resume.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package resume
-
-import (
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-)
-
-func ShouldSkipStep(status enum.StepStatus) bool {
- return status == enum.StepStatusSucceeded
-}
-
-func StepProgressByID(steps []entity.StepProgress, stepID string) *entity.StepProgress {
- for i := range steps {
- if steps[i].ID == stepID {
- return &steps[i]
- }
- }
- return nil
-}
-
-func PrepareStepsForRetry(steps []entity.StepProgress) []entity.StepProgress {
- out := make([]entity.StepProgress, len(steps))
- for i, step := range steps {
- out[i] = step
- if step.Status == enum.StepStatusSucceeded {
- continue
- }
- out[i].Status = enum.StepStatusPending
- out[i].StartedAt = nil
- out[i].EndedAt = nil
- out[i].Message = ""
- }
- return out
-}
-
-func FirstResumablePhase(steps []entity.StepProgress) string {
- for _, step := range steps {
- if step.Status != enum.StepStatusSucceeded {
- return step.ID
- }
- }
- if len(steps) == 0 {
- return ""
- }
- return steps[len(steps)-1].ID
-}
-
-func CalcProgressPercentage(steps []entity.StepProgress) int {
- if len(steps) == 0 {
- return 0
- }
- done := 0
- for _, step := range steps {
- if step.Status == enum.StepStatusSucceeded {
- done++
- }
- }
- return (done * 100) / len(steps)
-}
-
-func CountSucceededSteps(steps []entity.StepProgress) int {
- count := 0
- for _, step := range steps {
- if step.Status == enum.StepStatusSucceeded {
- count++
- }
- }
- return count
-}
diff --git a/old/backend/internal/model/job/resume/resume_test.go b/old/backend/internal/model/job/resume/resume_test.go
deleted file mode 100644
index 2229b6e..0000000
--- a/old/backend/internal/model/job/resume/resume_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package resume
-
-import (
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-)
-
-func TestPrepareStepsForRetry_PreservesSucceeded(t *testing.T) {
- started := int64(100)
- ended := int64(200)
- steps := []entity.StepProgress{
- {ID: "prepare", Status: enum.StepStatusSucceeded, StartedAt: &started, EndedAt: &ended, Message: "done"},
- {ID: "execute", Status: enum.StepStatusFailed, Message: "boom"},
- }
- out := PrepareStepsForRetry(steps)
- if out[0].Status != enum.StepStatusSucceeded {
- t.Fatalf("prepare status = %s", out[0].Status)
- }
- if out[1].Status != enum.StepStatusPending {
- t.Fatalf("execute status = %s", out[1].Status)
- }
- if out[1].Message != "" {
- t.Fatalf("execute message = %q", out[1].Message)
- }
-}
-
-func TestFirstResumablePhase(t *testing.T) {
- steps := []entity.StepProgress{
- {ID: "prepare", Status: enum.StepStatusSucceeded},
- {ID: "execute", Status: enum.StepStatusPending},
- }
- if got := FirstResumablePhase(steps); got != "execute" {
- t.Fatalf("phase = %s, want execute", got)
- }
-}
-
-func TestShouldSkipStep(t *testing.T) {
- if !ShouldSkipStep(enum.StepStatusSucceeded) {
- t.Fatal("succeeded should be skipped")
- }
- if ShouldSkipStep(enum.StepStatusPending) {
- t.Fatal("pending should not be skipped")
- }
-}
diff --git a/old/backend/internal/model/job/usecase/cancel_test.go b/old/backend/internal/model/job/usecase/cancel_test.go
deleted file mode 100644
index ed54696..0000000
--- a/old/backend/internal/model/job/usecase/cancel_test.go
+++ /dev/null
@@ -1,108 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- jobrepo "haixun-backend/internal/model/job/repository"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-func TestRequestCancel_RunningSetsRedisCancelSignal(t *testing.T) {
- ctx := context.Background()
- client := goredis.NewClient(&goredis.Options{Addr: "127.0.0.1:6379"})
- if err := client.Ping(ctx).Err(); err != nil {
- t.Skip("redis not available:", err)
- }
- defer client.Close()
-
- jobID := "507f1f77bcf86cd799439011"
- template := demoTemplate()
- run := &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusRunning,
- WorkerType: string(enum.WorkerTypeGo),
- }
-
- uc := NewUseCase(
- &memoryTemplateRepo{template: template},
- newMemoryRunRepo(run),
- &memoryScheduleRepo{},
- &memoryEventRepo{},
- jobrepo.NewRedisQueueRepository(client),
- )
-
- updated, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{
- JobID: jobID,
- Reason: "test cancel",
- })
- if err != nil {
- t.Fatalf("RequestCancel() error = %v", err)
- }
- if updated.Status != enum.RunStatusCancelRequested {
- t.Fatalf("status = %s, want cancel_requested", updated.Status)
- }
-
- value, err := client.Get(ctx, "jobs:cancel:"+jobID).Result()
- if err != nil {
- t.Fatalf("redis cancel key missing: %v", err)
- }
- if value != "test cancel" {
- t.Fatalf("cancel value = %q, want test cancel", value)
- }
- _ = client.Del(ctx, "jobs:cancel:"+jobID)
-}
-
-// A pending/queued run has no worker executing it yet, so cancelling it is
-// always safe. This must succeed even for templates like publish-analytics
-// that opt out of *cooperative* cancel (CancelPolicy.Supported = false),
-// otherwise a not-yet-started job can never be removed from the queue.
-func TestRequestCancel_PendingAllowedEvenWhenTemplateDoesNotSupportCancel(t *testing.T) {
- ctx := context.Background()
- template := publishAnalyticsTemplate()
- if template.CancelPolicy.Supported {
- t.Fatal("test setup assumes publish-analytics does not support cooperative cancel")
- }
- runs := newMemoryRunRepo(&entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusPending,
- WorkerType: template.Steps[0].WorkerType,
- })
- uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo())
-
- updated, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{
- JobID: "507f1f77bcf86cd799439011",
- Reason: "user requested cancel",
- })
- if err != nil {
- t.Fatalf("RequestCancel() error = %v", err)
- }
- if updated.Status != enum.RunStatusCancelled {
- t.Fatalf("status = %s, want cancelled", updated.Status)
- }
-}
-
-// Cooperative cancel (interrupting an already-running worker) must still
-// respect the template's CancelPolicy.
-func TestRequestCancel_RunningRejectedWhenTemplateDoesNotSupportCancel(t *testing.T) {
- ctx := context.Background()
- template := publishAnalyticsTemplate()
- runs := newMemoryRunRepo(&entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusRunning,
- WorkerType: template.Steps[0].WorkerType,
- })
- uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo())
-
- _, err := uc.RequestCancel(ctx, domusecase.CancelRunRequest{
- JobID: "507f1f77bcf86cd799439011",
- Reason: "user requested cancel",
- })
- if err == nil {
- t.Fatal("expected error cancelling a running job whose template does not support cooperative cancel")
- }
-}
diff --git a/old/backend/internal/model/job/usecase/dedupe_test.go b/old/backend/internal/model/job/usecase/dedupe_test.go
deleted file mode 100644
index 8c4162c..0000000
--- a/old/backend/internal/model/job/usecase/dedupe_test.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func TestCreateRun_BlocksNonRepeatableAfterSuccess(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- template.Repeatable = false
- template.DedupeKeys = []string{"scope_id"}
-
- runs := newMemoryRunRepo(nil)
- queue := newMemoryQueueRepo()
- uc := testUseCaseFull(template, runs, nil, queue)
-
- first, err := uc.CreateRun(ctx, domusecase.CreateRunRequest{
- TemplateType: template.Type,
- Scope: "user",
- ScopeID: "u1",
- })
- if err != nil {
- t.Fatalf("first CreateRun() error = %v", err)
- }
- first.Status = enum.RunStatusSucceeded
- first.DedupeKey = buildDedupeKey(template, "user", "u1", nil)
- _, _ = runs.Update(ctx, first)
-
- _, err = uc.CreateRun(ctx, domusecase.CreateRunRequest{
- TemplateType: template.Type,
- Scope: "user",
- ScopeID: "u1",
- })
- if err == nil {
- t.Fatal("expected duplicate completed job error")
- }
-}
-
-func TestCreateRun_DedupeLeaseBlocksParallel(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- template.Repeatable = true
- template.DedupeKeys = []string{"scope_id"}
-
- queue := newMemoryQueueRepo()
- uc := testUseCaseFull(template, newMemoryRunRepo(nil), nil, queue)
-
- first, err := uc.CreateRun(ctx, domusecase.CreateRunRequest{
- TemplateType: template.Type,
- Scope: "user",
- ScopeID: "u1",
- })
- if err != nil {
- t.Fatalf("first CreateRun() error = %v", err)
- }
- if first.Status != enum.RunStatusQueued {
- t.Fatalf("first status = %s, want queued", first.Status)
- }
-
- _, err = uc.CreateRun(ctx, domusecase.CreateRunRequest{
- TemplateType: template.Type,
- Scope: "user",
- ScopeID: "u1",
- })
- if err == nil {
- t.Fatal("expected in-progress dedupe error")
- }
-}
-
-func TestBuildDedupeKey_IncludesScanPostID(t *testing.T) {
- template := &entity.Template{
- Type: "generate-outreach-draft",
- DedupeKeys: []string{"scan_post_id"},
- }
- key1 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-1"})
- key2 := buildDedupeKey(template, "placement_topic", "topic-a", map[string]any{"scan_post_id": "post-2"})
- if key1 == "" || key2 == "" || key1 == key2 {
- t.Fatalf("dedupe keys should differ by scan_post_id: %q vs %q", key1, key2)
- }
-}
-
-func TestBuildDedupeKey_IncludesScopeID(t *testing.T) {
- template := &entity.Template{
- Type: "demo",
- DedupeKeys: []string{"scope_id", "target"},
- }
- key1 := buildDedupeKey(template, "user", "a", map[string]any{"target": "x"})
- key2 := buildDedupeKey(template, "user", "b", map[string]any{"target": "x"})
- if key1 == key2 {
- t.Fatal("dedupe keys should differ by scope_id")
- }
-}
diff --git a/old/backend/internal/model/job/usecase/enqueue_run_test.go b/old/backend/internal/model/job/usecase/enqueue_run_test.go
deleted file mode 100644
index 777a68c..0000000
--- a/old/backend/internal/model/job/usecase/enqueue_run_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-)
-
-func TestEnqueueRun_DoesNotOverwriteRunningClaim(t *testing.T) {
- ctx := context.Background()
- template := expandGraphTemplate()
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- uc := testUseCaseFull(template, runs, nil, queue).(*jobUseCase)
-
- id := primitive.NewObjectID()
- runs.run = &entity.Run{
- ID: id,
- TemplateType: template.Type,
- Status: enum.RunStatusRunning,
- WorkerType: "go",
- LockedBy: "worker-a",
- }
-
- pending := &entity.Run{
- ID: id,
- TemplateType: template.Type,
- Status: enum.RunStatusPending,
- WorkerType: "go",
- }
- updated, err := uc.enqueueRun(ctx, pending)
- if err != nil {
- t.Fatalf("enqueueRun() error = %v", err)
- }
- if updated.Status != enum.RunStatusRunning {
- t.Fatalf("status = %s, want running", updated.Status)
- }
- if len(queue.queues["go"]) != 0 {
- t.Fatalf("queue = %v, want duplicate enqueue removed", queue.queues["go"])
- }
-}
diff --git a/old/backend/internal/model/job/usecase/helpers.go b/old/backend/internal/model/job/usecase/helpers.go
deleted file mode 100644
index 222e8be..0000000
--- a/old/backend/internal/model/job/usecase/helpers.go
+++ /dev/null
@@ -1,215 +0,0 @@
-package usecase
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func stringFromAny(value any) string {
- switch v := value.(type) {
- case string:
- return strings.TrimSpace(v)
- default:
- return ""
- }
-}
-
-func extractRunActor(req domusecase.CreateRunRequest) (tenantID, ownerUID string) {
- tenantID = strings.TrimSpace(req.TenantID)
- ownerUID = strings.TrimSpace(req.OwnerUID)
- if req.Payload != nil {
- if tenantID == "" {
- tenantID = stringFromAny(req.Payload["tenant_id"])
- }
- if ownerUID == "" {
- ownerUID = stringFromAny(req.Payload["owner_uid"])
- }
- }
- scope := strings.TrimSpace(req.Scope)
- if ownerUID == "" && scope == "user" {
- ownerUID = strings.TrimSpace(req.ScopeID)
- }
- return tenantID, ownerUID
-}
-
-func buildDedupeKey(template *entity.Template, scope, scopeID string, payload map[string]any) string {
- parts := []string{template.Type, scope}
- for _, key := range template.DedupeKeys {
- switch key {
- case "scope_id":
- parts = append(parts, scopeID)
- default:
- if payload == nil {
- continue
- }
- value := dedupePayloadValue(payload, key)
- if value != "" {
- parts = append(parts, value)
- }
- }
- }
- raw := strings.Join(parts, "|")
- sum := sha256.Sum256([]byte(raw))
- return hex.EncodeToString(sum[:])
-}
-
-func dedupePayloadValue(payload map[string]any, key string) string {
- if payload == nil || key == "" {
- return ""
- }
- if value := stringFromAny(payload[key]); value != "" {
- return value
- }
- // Legacy aliases used by older payloads.
- switch key {
- case "target":
- return stringFromAny(payload["target"])
- case "benchmark_username":
- return stringFromAny(payload["benchmark_username"])
- default:
- return ""
- }
-}
-
-func firstPhaseFromTemplateSteps(steps []entity.StepProgress) string {
- if len(steps) == 0 {
- return ""
- }
- return steps[0].ID
-}
-
-func firstPhaseFromTemplate(template *entity.Template) string {
- if len(template.Steps) == 0 {
- return ""
- }
- return template.Steps[0].ID
-}
-
-func buildStepProgress(template *entity.Template) []entity.StepProgress {
- steps := make([]entity.StepProgress, 0, len(template.Steps))
- for _, step := range template.Steps {
- steps = append(steps, entity.StepProgress{ID: step.ID, Status: enum.StepStatusPending})
- }
- return steps
-}
-
-func workerTypeFromTemplate(template *entity.Template) string {
- if len(template.Steps) > 0 && strings.TrimSpace(template.Steps[0].WorkerType) != "" {
- return template.Steps[0].WorkerType
- }
- return string(enum.WorkerTypeGo)
-}
-
-func (u *jobUseCase) appendEvent(ctx context.Context, jobID, eventType, from, to, message string, metadata map[string]any) error {
- return u.events.Append(ctx, &entity.Event{
- JobID: jobID,
- Type: eventType,
- From: from,
- To: to,
- Message: message,
- Metadata: metadata,
- })
-}
-
-func (u *jobUseCase) enqueueRun(ctx context.Context, run *entity.Run) (*entity.Run, error) {
- jobID := run.ID.Hex()
- if run.ScheduledAt != nil && *run.ScheduledAt > clock.NowUnixNano() {
- return run, nil
- }
- if run.Status == enum.RunStatusQueued {
- if err := u.queue.Enqueue(ctx, run.WorkerType, jobID); err != nil {
- return nil, err
- }
- return run, nil
- }
- if err := u.queue.Enqueue(ctx, run.WorkerType, jobID); err != nil {
- return nil, err
- }
- fromStatus := string(run.Status)
- patch := *run
- patch.Status = enum.RunStatusQueued
- patch.LockedBy = ""
- patch.LockedUntil = nil
- patch.StartedAt = nil
- updated, err := u.runs.UpdateIfStatus(ctx, &patch, []enum.RunStatus{enum.RunStatusPending})
- if err != nil {
- fresh, findErr := u.runs.FindByID(ctx, jobID)
- if findErr == nil && fresh != nil {
- switch fresh.Status {
- case enum.RunStatusRunning:
- _ = u.queue.RemoveOneJob(ctx, run.WorkerType, jobID)
- return fresh, nil
- case enum.RunStatusQueued:
- return fresh, nil
- }
- }
- _ = u.queue.RemoveOneJob(ctx, run.WorkerType, jobID)
- return nil, err
- }
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusQueued), "job enqueued", nil)
- return updated, nil
-}
-
-func (u *jobUseCase) releaseDedupe(ctx context.Context, run *entity.Run) {
- if run.DedupeKey == "" {
- return
- }
- _ = u.queue.ReleaseDedupe(ctx, run.TemplateType, run.DedupeKey)
-}
-
-func (u *jobUseCase) enforceDedupe(ctx context.Context, template *entity.Template, dedupeKey string) error {
- if dedupeKey == "" {
- return nil
- }
- if !template.Repeatable {
- existing, err := u.runs.FindSucceededByDedupeKey(ctx, template.Type, dedupeKey)
- if err != nil {
- return err
- }
- if existing != nil {
- return app.For(code.Job).ResInvalidState("job already completed for dedupe key")
- }
- }
- return nil
-}
-
-func (u *jobUseCase) acquireDedupeLease(ctx context.Context, template *entity.Template, dedupeKey, jobID string) error {
- if dedupeKey == "" {
- return nil
- }
- ttl := template.TimeoutSeconds
- if ttl <= 0 {
- ttl = 3600
- }
- ok, err := u.queue.TryAcquireDedupe(ctx, template.Type, dedupeKey, jobID, ttl)
- if err != nil {
- return err
- }
- if !ok {
- return app.For(code.Job).ResInvalidState("duplicate job already in progress")
- }
- return nil
-}
-
-func retryBackoffSeconds(template *entity.Template, attempt int) int {
- if len(template.RetryPolicy.BackoffSeconds) == 0 {
- return 0
- }
- idx := attempt - 2
- if idx < 0 {
- idx = 0
- }
- if idx >= len(template.RetryPolicy.BackoffSeconds) {
- idx = len(template.RetryPolicy.BackoffSeconds) - 1
- }
- return template.RetryPolicy.BackoffSeconds[idx]
-}
diff --git a/old/backend/internal/model/job/usecase/maintenance.go b/old/backend/internal/model/job/usecase/maintenance.go
deleted file mode 100644
index 0784929..0000000
--- a/old/backend/internal/model/job/usecase/maintenance.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/enum"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func (u *jobUseCase) RunMaintenance(ctx context.Context) (domusecase.MaintenanceResult, error) {
- result := domusecase.MaintenanceResult{}
-
- pending, err := u.runs.FindPendingDue(ctx, clock.NowUnixNano(), 50)
- if err != nil {
- return result, err
- }
- for _, run := range pending {
- if _, err := u.enqueueRun(ctx, run); err == nil {
- result.EnqueuedPending++
- }
- }
-
- now := clock.NowUnixNano()
- cancelRuns, err := u.runs.FindCancelRequestedBefore(ctx, now, 50)
- if err != nil {
- return result, err
- }
- for _, run := range cancelRuns {
- template, err := u.templates.FindByType(ctx, run.TemplateType)
- if err != nil {
- continue
- }
- grace := clock.SecondsToNanos(template.CancelPolicy.GraceSeconds)
- if run.CancelRequestedAt == nil || now-*run.CancelRequestedAt < grace {
- continue
- }
- jobID := run.ID.Hex()
- fromStatus := run.Status
- run.Status = enum.RunStatusCancelled
- run.CompletedAt = &now
- run.Progress.Summary = "cancelled after grace timeout"
- updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{fromStatus})
- if err != nil {
- continue
- }
- _ = u.queue.ReleaseLock(ctx, jobID, run.LockedBy)
- _ = u.queue.ClearCancelSignal(ctx, jobID)
- u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, jobID, "status_changed", string(fromStatus), string(enum.RunStatusCancelled), "cancel grace timeout", nil)
- result.ReapedCancelGrace++
- }
-
- stuckRuns, err := u.runs.FindQueuedWithLock(ctx, 50)
- if err != nil {
- return result, err
- }
- for _, run := range stuckRuns {
- jobID := run.ID.Hex()
- if run.StartedAt != nil && run.LockedUntil != nil && *run.LockedUntil > now {
- fromStatus := string(run.Status)
- patch := *run
- patch.Status = enum.RunStatusRunning
- if patch.Progress.Summary == "" || patch.Progress.Summary == "waiting for worker" {
- patch.Progress.Summary = "worker claimed job (status repaired)"
- }
- updated, err := u.runs.UpdateIfStatus(ctx, &patch, []enum.RunStatus{
- enum.RunStatusPending,
- enum.RunStatusQueued,
- })
- if err != nil {
- continue
- }
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusRunning), "repaired queued job with active worker lock", nil)
- result.RepairedStuckClaims++
- _ = updated
- continue
- }
- fromStatus := string(run.Status)
- workerID := run.LockedBy
- patch := *run
- patch.LockedBy = ""
- patch.LockedUntil = nil
- patch.StartedAt = nil
- patch.Progress.Summary = "waiting for worker"
- patch.Progress.Percentage = 0
- if _, err := u.runs.UpdateIfStatus(ctx, &patch, []enum.RunStatus{
- enum.RunStatusPending,
- enum.RunStatusQueued,
- }); err != nil {
- continue
- }
- _ = u.queue.ReleaseLock(ctx, jobID, workerID)
- if err := u.queue.Enqueue(ctx, patch.WorkerType, jobID); err != nil {
- continue
- }
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusQueued), "requeued orphaned lock", nil)
- result.ReapedOrphanedLocks++
- }
-
- expiredRuns, err := u.runs.FindRunningTimedOut(ctx, now, 50)
- if err != nil {
- return result, err
- }
- for _, run := range expiredRuns {
- jobID := run.ID.Hex()
- fromStatusEnum := run.Status
- fromStatus := string(fromStatusEnum)
- run.Status = enum.RunStatusExpired
- run.CompletedAt = &now
- run.Progress.Summary = "job lock expired"
- updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{fromStatusEnum})
- if err != nil {
- continue
- }
- _ = u.queue.ReleaseLock(ctx, jobID, run.LockedBy)
- _ = u.queue.ClearCancelSignal(ctx, jobID)
- u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusExpired), "job lock expired", nil)
- result.ReapedExpiredLocks++
- }
-
- return result, nil
-}
diff --git a/old/backend/internal/model/job/usecase/maintenance_stuck_test.go b/old/backend/internal/model/job/usecase/maintenance_stuck_test.go
deleted file mode 100644
index 668e737..0000000
--- a/old/backend/internal/model/job/usecase/maintenance_stuck_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
- "time"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-)
-
-func TestRunMaintenance_RepairsQueuedWithActiveLock(t *testing.T) {
- ctx := context.Background()
- template := expandGraphTemplate()
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- started := clock.Now().UnixNano()
- lockedUntil := clock.Now().Add(10 * time.Minute).UnixNano()
- runs.run = &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusQueued,
- WorkerType: "go",
- LockedBy: "worker-a",
- StartedAt: &started,
- LockedUntil: &lockedUntil,
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- result, err := uc.RunMaintenance(ctx)
- if err != nil {
- t.Fatalf("RunMaintenance() error = %v", err)
- }
- if result.RepairedStuckClaims != 1 {
- t.Fatalf("RepairedStuckClaims = %d, want 1", result.RepairedStuckClaims)
- }
- if runs.run.Status != enum.RunStatusRunning {
- t.Fatalf("status = %s, want running", runs.run.Status)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/maintenance_test.go b/old/backend/internal/model/job/usecase/maintenance_test.go
deleted file mode 100644
index 7296729..0000000
--- a/old/backend/internal/model/job/usecase/maintenance_test.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
- "time"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-)
-
-func TestRunMaintenance_EnqueuePendingRetry(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- scheduled := clock.Now().Add(-time.Second).UnixNano()
- runs.run = &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusPending,
- WorkerType: "go",
- ScheduledAt: &scheduled,
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- result, err := uc.RunMaintenance(ctx)
- if err != nil {
- t.Fatalf("RunMaintenance() error = %v", err)
- }
- if result.EnqueuedPending != 1 {
- t.Fatalf("EnqueuedPending = %d, want 1", result.EnqueuedPending)
- }
- if runs.run.Status != enum.RunStatusQueued {
- t.Fatalf("status = %s, want queued", runs.run.Status)
- }
-}
-
-func TestRunMaintenance_ReapCancelGrace(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- template.CancelPolicy.GraceSeconds = 1
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- cancelAt := clock.Now().Add(-2 * time.Second).UnixNano()
- runs.run = &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusCancelRequested,
- CancelRequestedAt: &cancelAt,
- DedupeKey: "dedupe-1",
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- result, err := uc.RunMaintenance(ctx)
- if err != nil {
- t.Fatalf("RunMaintenance() error = %v", err)
- }
- if result.ReapedCancelGrace != 1 {
- t.Fatalf("ReapedCancelGrace = %d, want 1", result.ReapedCancelGrace)
- }
- if runs.run.Status != enum.RunStatusCancelled {
- t.Fatalf("status = %s, want cancelled", runs.run.Status)
- }
-}
-
-func TestRunMaintenance_ReapCancelGrace_SkipsIfAlreadyResolved(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- template.CancelPolicy.GraceSeconds = 1
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- cancelAt := clock.Now().Add(-2 * time.Second).UnixNano()
- runs.run = &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusCancelRequested,
- CancelRequestedAt: &cancelAt,
- DedupeKey: "dedupe-1",
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- // Simulate the worker acknowledging cancel (or completing/failing the job)
- // concurrently, right after the reaper reads it but before it writes back.
- runs.raceAfterFind = func() { runs.run.Status = enum.RunStatusSucceeded }
-
- result, err := uc.RunMaintenance(ctx)
- if err != nil {
- t.Fatalf("RunMaintenance() error = %v", err)
- }
- if result.ReapedCancelGrace != 0 {
- t.Fatalf("ReapedCancelGrace = %d, want 0 (must not clobber concurrently resolved status)", result.ReapedCancelGrace)
- }
- if runs.run.Status != enum.RunStatusSucceeded {
- t.Fatalf("status = %s, want succeeded (unchanged)", runs.run.Status)
- }
-}
-
-func TestRunMaintenance_ReapExpiredLock(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- lockedUntil := clock.Now().Add(-time.Second).UnixNano()
- runs.run = &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusRunning,
- LockedUntil: &lockedUntil,
- DedupeKey: "dedupe-2",
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- result, err := uc.RunMaintenance(ctx)
- if err != nil {
- t.Fatalf("RunMaintenance() error = %v", err)
- }
- if result.ReapedExpiredLocks != 1 {
- t.Fatalf("ReapedExpiredLocks = %d, want 1", result.ReapedExpiredLocks)
- }
- if runs.run.Status != enum.RunStatusExpired {
- t.Fatalf("status = %s, want expired", runs.run.Status)
- }
-}
-
-func TestRunMaintenance_ReapExpiredLock_SkipsIfAlreadyResolved(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- lockedUntil := clock.Now().Add(-time.Second).UnixNano()
- runs.run = &entity.Run{
- TemplateType: template.Type,
- Status: enum.RunStatusRunning,
- LockedUntil: &lockedUntil,
- DedupeKey: "dedupe-2",
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- // Simulate the worker completing the job right after the reaper reads it
- // but before it writes back; the reaper must not overwrite it to expired.
- runs.raceAfterFind = func() { runs.run.Status = enum.RunStatusSucceeded }
-
- result, err := uc.RunMaintenance(ctx)
- if err != nil {
- t.Fatalf("RunMaintenance() error = %v", err)
- }
- if result.ReapedExpiredLocks != 0 {
- t.Fatalf("ReapedExpiredLocks = %d, want 0 (must not clobber concurrently resolved status)", result.ReapedExpiredLocks)
- }
- if runs.run.Status != enum.RunStatusSucceeded {
- t.Fatalf("status = %s, want succeeded (unchanged)", runs.run.Status)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/refresh_lock_test.go b/old/backend/internal/model/job/usecase/refresh_lock_test.go
deleted file mode 100644
index fdb9b11..0000000
--- a/old/backend/internal/model/job/usecase/refresh_lock_test.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
- "time"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-func TestRefreshRunLock_ExtendsMongoLockedUntil(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- lockedUntil := clock.Now().Add(-time.Minute).UnixNano()
- runs.run = &entity.Run{
- ID: primitive.NewObjectID(),
- TemplateType: template.Type,
- Status: enum.RunStatusRunning,
- LockedBy: "worker-a",
- LockedUntil: &lockedUntil,
- }
- uc := testUseCaseFull(template, runs, nil, queue)
-
- if _, err := queue.TryLock(ctx, runs.run.ID.Hex(), "worker-a", 600); err != nil {
- t.Fatalf("TryLock() error = %v", err)
- }
- if err := uc.RefreshRunLock(ctx, runs.run.ID.Hex(), "worker-a", 600); err != nil {
- t.Fatalf("RefreshRunLock() error = %v", err)
- }
- if runs.run.LockedUntil == nil || *runs.run.LockedUntil <= clock.NowUnixNano() {
- t.Fatalf("locked_until = %v, want future timestamp", runs.run.LockedUntil)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/retry_max_attempts_test.go b/old/backend/internal/model/job/usecase/retry_max_attempts_test.go
deleted file mode 100644
index 8576049..0000000
--- a/old/backend/internal/model/job/usecase/retry_max_attempts_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-func TestRetryRun_AllowsManualRetryWhenAttemptReachedMaxAttempts(t *testing.T) {
- ctx := context.Background()
- template := generateRewriteDraftTemplate()
- jobID := primitive.NewObjectID()
- runs := newMemoryRunRepo(&entity.Run{
- ID: jobID,
- TemplateType: template.Type,
- Status: enum.RunStatusFailed,
- WorkerType: workerTypeGenerateRewriteDraft,
- Attempt: 1,
- MaxAttempts: 1,
- Progress: entity.RunProgress{
- Steps: []entity.StepProgress{{ID: "rewrite_draft_generate", Status: enum.StepStatusFailed}},
- },
- })
- queue := newMemoryQueueRepo()
- uc := testUseCaseFull(template, runs, nil, queue)
-
- updated, err := uc.RetryRun(ctx, jobID.Hex())
- if err != nil {
- t.Fatalf("RetryRun() error = %v", err)
- }
- if updated.Status != enum.RunStatusQueued && updated.Status != enum.RunStatusPending {
- t.Fatalf("status = %s, want queued or pending", updated.Status)
- }
- if updated.Attempt != 2 {
- t.Fatalf("attempt = %d, want 2", updated.Attempt)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/retry_resume_test.go b/old/backend/internal/model/job/usecase/retry_resume_test.go
deleted file mode 100644
index 747bb7a..0000000
--- a/old/backend/internal/model/job/usecase/retry_resume_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-func TestRetryRun_PreservesSucceededSteps(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- jobID := primitive.NewObjectID()
- runs := newMemoryRunRepo(&entity.Run{
- ID: jobID,
- TemplateType: template.Type,
- Status: enum.RunStatusFailed,
- WorkerType: "go",
- Attempt: 1,
- MaxAttempts: 3,
- Phase: "execute",
- Progress: entity.RunProgress{
- Steps: []entity.StepProgress{
- {ID: "prepare", Status: enum.StepStatusSucceeded, Message: "done"},
- {ID: "execute", Status: enum.StepStatusFailed, Message: "boom"},
- {ID: "finalize", Status: enum.StepStatusPending},
- },
- },
- })
- uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo())
-
- updated, err := uc.RetryRun(ctx, jobID.Hex())
- if err != nil {
- t.Fatalf("RetryRun() error = %v", err)
- }
- if updated.Progress.Steps[0].Status != enum.StepStatusSucceeded {
- t.Fatalf("prepare = %s, want succeeded", updated.Progress.Steps[0].Status)
- }
- if updated.Progress.Steps[1].Status != enum.StepStatusPending {
- t.Fatalf("execute = %s, want pending", updated.Progress.Steps[1].Status)
- }
- if updated.Phase != "execute" {
- t.Fatalf("phase = %s, want execute", updated.Phase)
- }
- if updated.Progress.Percentage != 33 {
- t.Fatalf("percentage = %d, want 33", updated.Progress.Percentage)
- }
-}
-
-func TestRetryRun_ExpiredResumesFromCheckpoint(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- jobID := primitive.NewObjectID()
- runs := newMemoryRunRepo(&entity.Run{
- ID: jobID,
- TemplateType: template.Type,
- Status: enum.RunStatusExpired,
- WorkerType: "go",
- Attempt: 1,
- MaxAttempts: 3,
- DedupeKey: "dedupe-expired",
- Progress: entity.RunProgress{
- Steps: []entity.StepProgress{
- {ID: "prepare", Status: enum.StepStatusSucceeded},
- {ID: "execute", Status: enum.StepStatusRunning},
- {ID: "finalize", Status: enum.StepStatusPending},
- },
- },
- })
- uc := testUseCaseFull(template, runs, nil, newMemoryQueueRepo())
-
- updated, err := uc.RetryRun(ctx, jobID.Hex())
- if err != nil {
- t.Fatalf("RetryRun() error = %v", err)
- }
- if updated.Progress.Steps[0].Status != enum.StepStatusSucceeded {
- t.Fatalf("prepare = %s, want succeeded", updated.Progress.Steps[0].Status)
- }
- if updated.Phase != "execute" {
- t.Fatalf("phase = %s, want execute", updated.Phase)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/retry_test.go b/old/backend/internal/model/job/usecase/retry_test.go
deleted file mode 100644
index 554014e..0000000
--- a/old/backend/internal/model/job/usecase/retry_test.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-func TestRetryRun_SchedulesBackoff(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- template.RetryPolicy = entity.RetryPolicy{
- MaxAttempts: 3,
- BackoffSeconds: []int{30, 60},
- }
- jobID := primitive.NewObjectID()
- runs := newMemoryRunRepo(&entity.Run{
- ID: jobID,
- TemplateType: template.Type,
- Status: enum.RunStatusFailed,
- WorkerType: "go",
- Attempt: 1,
- MaxAttempts: 3,
- Progress: entity.RunProgress{
- Steps: []entity.StepProgress{{ID: "prepare", Status: enum.StepStatusFailed}},
- },
- })
- queue := newMemoryQueueRepo()
- uc := testUseCaseFull(template, runs, nil, queue)
-
- updated, err := uc.RetryRun(ctx, jobID.Hex())
- if err != nil {
- t.Fatalf("RetryRun() error = %v", err)
- }
- if updated.Status != enum.RunStatusPending {
- t.Fatalf("status = %s, want pending", updated.Status)
- }
- if updated.ScheduledAt == nil {
- t.Fatal("expected scheduled_at for backoff retry")
- }
- if *updated.ScheduledAt <= clock.NowUnixNano() {
- t.Fatal("scheduled_at should be in the future")
- }
- if len(queue.queued("go")) != 0 {
- t.Fatal("backoff retry should not enqueue immediately")
- }
-}
-
-func TestRetryRun_BackoffReschedule_DoesNotClobberConcurrentCancel(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- template.RetryPolicy = entity.RetryPolicy{
- MaxAttempts: 3,
- BackoffSeconds: []int{30, 60},
- }
- jobID := primitive.NewObjectID()
- runs := newMemoryRunRepo(&entity.Run{
- ID: jobID,
- TemplateType: template.Type,
- Status: enum.RunStatusFailed,
- WorkerType: "go",
- Attempt: 1,
- MaxAttempts: 3,
- Progress: entity.RunProgress{
- Steps: []entity.StepProgress{{ID: "prepare", Status: enum.StepStatusFailed}},
- },
- })
- queue := newMemoryQueueRepo()
- uc := testUseCaseFull(template, runs, nil, queue)
-
- // Simulate a user cancelling the job right after RetryRun transitions it
- // to "pending" but before the backoff reschedule writes ScheduledAt back.
- // The reschedule write must not silently revive the cancelled job.
- runs.raceAfterUpdateIfStatus = func() { runs.run.Status = enum.RunStatusCancelled }
-
- _, err := uc.RetryRun(ctx, jobID.Hex())
- if err == nil {
- t.Fatal("expected error when backoff reschedule races with a concurrent cancel")
- }
- if runs.run.Status != enum.RunStatusCancelled {
- t.Fatalf("status = %s, want cancelled (must not be revived to pending)", runs.run.Status)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/schedule.go b/old/backend/internal/model/job/usecase/schedule.go
deleted file mode 100644
index 902a535..0000000
--- a/old/backend/internal/model/job/usecase/schedule.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobcron "haixun-backend/internal/model/job/cron"
- "haixun-backend/internal/model/job/domain/entity"
- domrepo "haixun-backend/internal/model/job/domain/repository"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func (u *jobUseCase) ListSchedules(ctx context.Context, scope, scopeID string, page, pageSize int64) ([]*entity.Schedule, int64, int64, int64, int64, error) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 20
- }
- if pageSize > 200 {
- pageSize = 200
- }
- offset := (page - 1) * pageSize
- items, total, err := u.schedules.List(ctx, domrepo.ScheduleListFilter{
- Scope: scope,
- ScopeID: scopeID,
- }, offset, pageSize)
- if err != nil {
- return nil, 0, 0, 0, 0, err
- }
- totalPages := int64(0)
- if total > 0 {
- totalPages = (total + pageSize - 1) / pageSize
- }
- return items, total, page, pageSize, totalPages, nil
-}
-
-func (u *jobUseCase) GetSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error) {
- return u.schedules.FindByID(ctx, scheduleID)
-}
-
-func (u *jobUseCase) CreateSchedule(ctx context.Context, req domusecase.CreateScheduleRequest) (*entity.Schedule, error) {
- templateType := strings.TrimSpace(req.TemplateType)
- scope := strings.TrimSpace(req.Scope)
- scopeID := strings.TrimSpace(req.ScopeID)
- cronExpr := strings.TrimSpace(req.Cron)
- if templateType == "" || scope == "" || scopeID == "" || cronExpr == "" {
- return nil, app.For(code.Job).InputMissingRequired("template_type, scope, scope_id, and cron are required")
- }
- if _, err := u.templates.FindByType(ctx, templateType); err != nil {
- return nil, err
- }
-
- timezone := req.Timezone
- if timezone == "" {
- timezone = "UTC"
- }
- nextRunAt, err := jobcron.NextRunAt(cronExpr, timezone, clock.Now())
- if err != nil {
- return nil, app.For(code.Job).InputInvalidFormat(err.Error())
- }
-
- schedule := &entity.Schedule{
- TemplateType: templateType,
- Scope: scope,
- ScopeID: scopeID,
- Enabled: req.Enabled,
- Cron: cronExpr,
- Timezone: timezone,
- PayloadTemplate: req.PayloadTemplate,
- NextRunAt: nextRunAt,
- }
- return u.schedules.Create(ctx, schedule)
-}
-
-func (u *jobUseCase) UpdateSchedule(ctx context.Context, req domusecase.UpdateScheduleRequest) (*entity.Schedule, error) {
- schedule, err := u.schedules.FindByID(ctx, req.ID)
- if err != nil {
- return nil, err
- }
- if cronExpr := strings.TrimSpace(req.Cron); cronExpr != "" {
- schedule.Cron = cronExpr
- }
- if tz := strings.TrimSpace(req.Timezone); tz != "" {
- schedule.Timezone = tz
- }
- if req.PayloadTemplate != nil {
- schedule.PayloadTemplate = req.PayloadTemplate
- }
- if req.Enabled != nil {
- schedule.Enabled = *req.Enabled
- }
-
- nextRunAt, err := jobcron.NextRunAt(schedule.Cron, schedule.Timezone, clock.Now())
- if err != nil {
- return nil, app.For(code.Job).InputInvalidFormat(err.Error())
- }
- schedule.NextRunAt = nextRunAt
- return u.schedules.Update(ctx, schedule)
-}
-
-func (u *jobUseCase) EnableSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error) {
- schedule, err := u.schedules.FindByID(ctx, scheduleID)
- if err != nil {
- return nil, err
- }
- schedule.Enabled = true
- nextRunAt, err := jobcron.NextRunAt(schedule.Cron, schedule.Timezone, clock.Now())
- if err != nil {
- return nil, app.For(code.Job).InputInvalidFormat(err.Error())
- }
- schedule.NextRunAt = nextRunAt
- return u.schedules.Update(ctx, schedule)
-}
-
-func (u *jobUseCase) DisableSchedule(ctx context.Context, scheduleID string) (*entity.Schedule, error) {
- schedule, err := u.schedules.FindByID(ctx, scheduleID)
- if err != nil {
- return nil, err
- }
- schedule.Enabled = false
- return u.schedules.Update(ctx, schedule)
-}
-
-func (u *jobUseCase) DeleteSchedule(ctx context.Context, scheduleID string) error {
- if strings.TrimSpace(scheduleID) == "" {
- return app.For(code.Job).InputMissingRequired("schedule id is required")
- }
- if _, err := u.schedules.FindByID(ctx, scheduleID); err != nil {
- return err
- }
- return u.schedules.Delete(ctx, scheduleID)
-}
-
-func (u *jobUseCase) RunSchedulerTick(ctx context.Context, holder string) (int, error) {
- ok, err := u.queue.TrySchedulerLock(ctx, holder, 55)
- if err != nil {
- return 0, err
- }
- if !ok {
- return 0, nil
- }
- defer func() { _ = u.queue.ReleaseSchedulerLock(ctx, holder) }()
-
- now := clock.NowUnixNano()
- due, err := u.schedules.FindDue(ctx, now, 50)
- if err != nil {
- return 0, err
- }
-
- created := 0
- var firstErr error
- for _, schedule := range due {
- payload := buildScheduleRunPayload(schedule, now)
- if _, err := u.CreateRun(ctx, domusecase.CreateRunRequest{
- TemplateType: schedule.TemplateType,
- Scope: schedule.Scope,
- ScopeID: schedule.ScopeID,
- Payload: payload,
- }); err != nil {
- if firstErr == nil {
- firstErr = err
- }
- continue
- }
- created++
-
- lastRun := now
- schedule.LastRunAt = &lastRun
- nextRunAt, calcErr := jobcron.NextRunAt(schedule.Cron, schedule.Timezone, clock.Now())
- if calcErr != nil {
- continue
- }
- schedule.NextRunAt = nextRunAt
- _, _ = u.schedules.Update(ctx, schedule)
- }
- return created, firstErr
-}
diff --git a/old/backend/internal/model/job/usecase/schedule_payload.go b/old/backend/internal/model/job/usecase/schedule_payload.go
deleted file mode 100644
index 667ce59..0000000
--- a/old/backend/internal/model/job/usecase/schedule_payload.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package usecase
-
-import (
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-const schedulePayloadKey = "_schedule"
-
-// buildScheduleRunPayload clones the schedule template payload and injects scheduler metadata.
-// Persisted timestamps use UTC unix nanoseconds; timezone is the cron interpretation zone.
-func buildScheduleRunPayload(schedule *entity.Schedule, triggeredAtUTC int64) map[string]any {
- payload := map[string]any{}
- for key, value := range schedule.PayloadTemplate {
- payload[key] = value
- }
- payload[schedulePayloadKey] = map[string]any{
- "id": schedule.ID.Hex(),
- "timezone": schedule.Timezone,
- "cron": schedule.Cron,
- "triggered_at": triggeredAtUTC,
- "storage_tz": clock.StorageTimezone,
- }
- return payload
-}
diff --git a/old/backend/internal/model/job/usecase/schedule_payload_test.go b/old/backend/internal/model/job/usecase/schedule_payload_test.go
deleted file mode 100644
index 3bc06ec..0000000
--- a/old/backend/internal/model/job/usecase/schedule_payload_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package usecase
-
-import (
- "testing"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-func TestBuildScheduleRunPayload_InjectsTimezoneMetadata(t *testing.T) {
- triggeredAt := clock.NowUnixNano()
- schedule := &entity.Schedule{
- ID: primitive.NewObjectID(),
- Timezone: "Asia/Taipei",
- Cron: "0 9 * * *",
- PayloadTemplate: map[string]any{
- "target": "demo",
- },
- }
-
- payload := buildScheduleRunPayload(schedule, triggeredAt)
- if payload["target"] != "demo" {
- t.Fatalf("target = %v, want demo", payload["target"])
- }
- meta, ok := payload[schedulePayloadKey].(map[string]any)
- if !ok {
- t.Fatal("expected _schedule metadata")
- }
- if meta["timezone"] != "Asia/Taipei" {
- t.Fatalf("timezone = %v", meta["timezone"])
- }
- if meta["storage_tz"] != clock.StorageTimezone {
- t.Fatalf("storage_tz = %v", meta["storage_tz"])
- }
- if meta["triggered_at"] != triggeredAt {
- t.Fatalf("triggered_at = %v", meta["triggered_at"])
- }
-}
diff --git a/old/backend/internal/model/job/usecase/schedule_test.go b/old/backend/internal/model/job/usecase/schedule_test.go
deleted file mode 100644
index 5a7e850..0000000
--- a/old/backend/internal/model/job/usecase/schedule_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func TestRunSchedulerTick_CreatesRun(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- schedules := &memoryScheduleRepo{}
- queue := newMemoryQueueRepo()
- runs := newMemoryRunRepo(nil)
- uc := testUseCaseFull(template, runs, schedules, queue)
-
- now := clock.NowUnixNano()
- schedule := &entity.Schedule{
- TemplateType: template.Type,
- Scope: "user",
- ScopeID: "scheduler-user",
- Enabled: true,
- Cron: "0 * * * *",
- Timezone: "Asia/Taipei",
- NextRunAt: now - 1,
- }
- _, _ = schedules.Create(ctx, schedule)
-
- created, err := uc.RunSchedulerTick(ctx, "test-holder")
- if err != nil {
- t.Fatalf("RunSchedulerTick() error = %v", err)
- }
- if created != 1 {
- t.Fatalf("created = %d, want 1", created)
- }
- if len(queue.queued(workerTypeDemo)) == 0 {
- t.Fatal("expected queued job from scheduler tick")
- }
- if runs.run == nil {
- t.Fatal("expected run to be created")
- }
- meta, ok := runs.run.Payload[schedulePayloadKey].(map[string]any)
- if !ok {
- t.Fatal("expected _schedule payload metadata")
- }
- if meta["timezone"] != "Asia/Taipei" {
- t.Fatalf("timezone = %v, want Asia/Taipei", meta["timezone"])
- }
- if meta["storage_tz"] != clock.StorageTimezone {
- t.Fatalf("storage_tz = %v, want %s", meta["storage_tz"], clock.StorageTimezone)
- }
- triggeredAt, ok := meta["triggered_at"].(int64)
- if !ok || triggeredAt <= 0 {
- t.Fatalf("triggered_at = %v, want positive unix nano UTC", meta["triggered_at"])
- }
-}
-
-func TestCreateSchedule_InvalidCron(t *testing.T) {
- ctx := context.Background()
- template := demoTemplate()
- uc := testUseCaseFull(template, newMemoryRunRepo(nil), &memoryScheduleRepo{}, newMemoryQueueRepo())
-
- _, err := uc.CreateSchedule(ctx, domusecase.CreateScheduleRequest{
- TemplateType: template.Type,
- Scope: "user",
- ScopeID: "u1",
- Cron: "invalid",
- })
- if err == nil {
- t.Fatal("expected invalid cron error")
- }
-}
diff --git a/old/backend/internal/model/job/usecase/step_resume.go b/old/backend/internal/model/job/usecase/step_resume.go
deleted file mode 100644
index bf28b74..0000000
--- a/old/backend/internal/model/job/usecase/step_resume.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package usecase
-
-import (
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/resume"
-)
-
-func prepareStepsForRetry(steps []entity.StepProgress) []entity.StepProgress {
- return resume.PrepareStepsForRetry(steps)
-}
-
-func firstResumablePhase(steps []entity.StepProgress) string {
- return resume.FirstResumablePhase(steps)
-}
-
-func calcProgressPercentage(steps []entity.StepProgress) int {
- return resume.CalcProgressPercentage(steps)
-}
diff --git a/old/backend/internal/model/job/usecase/template.go b/old/backend/internal/model/job/usecase/template.go
deleted file mode 100644
index c2f2916..0000000
--- a/old/backend/internal/model/job/usecase/template.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func (u *jobUseCase) UpsertTemplate(ctx context.Context, req domusecase.UpsertTemplateRequest) (*entity.Template, error) {
- templateType := strings.TrimSpace(req.Type)
- if templateType == "" {
- return nil, app.For(code.Job).InputMissingRequired("template type is required")
- }
- if strings.TrimSpace(req.Name) == "" {
- return nil, app.For(code.Job).InputMissingRequired("template name is required")
- }
- if len(req.Steps) == 0 {
- return nil, app.For(code.Job).InputMissingRequired("template steps are required")
- }
-
- version := req.Version
- if version <= 0 {
- version = 1
- }
- timeout := req.TimeoutSeconds
- if timeout <= 0 {
- timeout = 600
- }
-
- template := &entity.Template{
- Type: templateType,
- Version: version,
- Name: req.Name,
- Description: req.Description,
- Enabled: req.Enabled,
- Repeatable: req.Repeatable,
- ConcurrencyPolicy: req.ConcurrencyPolicy,
- DedupeKeys: req.DedupeKeys,
- TimeoutSeconds: timeout,
- CancelPolicy: req.CancelPolicy,
- RetryPolicy: req.RetryPolicy,
- Steps: req.Steps,
- }
- if template.ConcurrencyPolicy == "" {
- template.ConcurrencyPolicy = "reject_same_scope"
- }
- if template.CancelPolicy.Mode == "" {
- template.CancelPolicy.Mode = "cooperative"
- }
- if template.CancelPolicy.GraceSeconds <= 0 {
- template.CancelPolicy.GraceSeconds = 30
- }
- return u.templates.Upsert(ctx, template)
-}
-
-func (u *jobUseCase) ListJobEvents(ctx context.Context, jobID string, limit int64) ([]*entity.Event, error) {
- if strings.TrimSpace(jobID) == "" {
- return nil, app.For(code.Job).InputMissingRequired("job id is required")
- }
- if _, err := u.runs.FindByID(ctx, jobID); err != nil {
- return nil, err
- }
- return u.events.ListByJobID(ctx, jobID, limit)
-}
diff --git a/old/backend/internal/model/job/usecase/template_test.go b/old/backend/internal/model/job/usecase/template_test.go
deleted file mode 100644
index f6bba02..0000000
--- a/old/backend/internal/model/job/usecase/template_test.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-func TestUpsertTemplate_SetsDefaults(t *testing.T) {
- ctx := context.Background()
- repo := &memoryTemplateRepo{}
- uc := NewUseCase(repo, newMemoryRunRepo(nil), &memoryScheduleRepo{}, &memoryEventRepo{}, newMemoryQueueRepo())
-
- template, err := uc.UpsertTemplate(ctx, domusecase.UpsertTemplateRequest{
- Type: "custom_job",
- Name: "Custom Job",
- Enabled: true,
- Steps: []entity.TemplateStep{{
- ID: "step1",
- Name: "Step 1",
- WorkerType: "go",
- }},
- })
- if err != nil {
- t.Fatalf("UpsertTemplate() error = %v", err)
- }
- if template.ConcurrencyPolicy != "reject_same_scope" {
- t.Fatalf("ConcurrencyPolicy = %q", template.ConcurrencyPolicy)
- }
- if template.CancelPolicy.GraceSeconds != 30 {
- t.Fatalf("GraceSeconds = %d, want 30", template.CancelPolicy.GraceSeconds)
- }
-}
diff --git a/old/backend/internal/model/job/usecase/test_mocks.go b/old/backend/internal/model/job/usecase/test_mocks.go
deleted file mode 100644
index a383abd..0000000
--- a/old/backend/internal/model/job/usecase/test_mocks.go
+++ /dev/null
@@ -1,504 +0,0 @@
-package usecase
-
-import (
- "context"
- "errors"
- "strings"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domrepo "haixun-backend/internal/model/job/domain/repository"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "sync"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-type memoryTemplateRepo struct {
- template *entity.Template
-}
-
-func (m *memoryTemplateRepo) EnsureIndexes(context.Context) error { return nil }
-func (m *memoryTemplateRepo) List(context.Context) ([]*entity.Template, error) {
- if m.template == nil {
- return nil, nil
- }
- return []*entity.Template{m.template}, nil
-}
-func (m *memoryTemplateRepo) FindByType(context.Context, string) (*entity.Template, error) {
- return m.template, nil
-}
-func (m *memoryTemplateRepo) Upsert(_ context.Context, template *entity.Template) (*entity.Template, error) {
- m.template = template
- return template, nil
-}
-
-type memoryRunRepo struct {
- mu sync.Mutex
- run *entity.Run
- succeeded map[string]*entity.Run
- // raceAfterFind, when set, fires exactly once right after a Find* method
- // captures its result snapshot. It lets tests simulate another writer
- // (worker ack, complete, fail...) mutating the stored run concurrently,
- // between the reaper's read and its guarded write.
- raceAfterFind func()
- // raceAfterUpdateIfStatus, when set, fires exactly once right after a
- // UpdateIfStatus call succeeds. It lets tests simulate another writer
- // racing in between two sequential guarded updates on the same run
- // (e.g. RetryRun's pending transition followed by its backoff reschedule).
- raceAfterUpdateIfStatus func()
-}
-
-func (m *memoryRunRepo) fireRaceAfterFindLocked() {
- if m.raceAfterFind == nil {
- return
- }
- fn := m.raceAfterFind
- m.raceAfterFind = nil
- fn()
-}
-
-func (m *memoryRunRepo) fireRaceAfterUpdateIfStatusLocked() {
- if m.raceAfterUpdateIfStatus == nil {
- return
- }
- fn := m.raceAfterUpdateIfStatus
- m.raceAfterUpdateIfStatus = nil
- fn()
-}
-
-func newMemoryRunRepo(run *entity.Run) *memoryRunRepo {
- return &memoryRunRepo{run: run, succeeded: map[string]*entity.Run{}}
-}
-
-func (m *memoryRunRepo) EnsureIndexes(context.Context) error { return nil }
-func (m *memoryRunRepo) Create(ctx context.Context, run *entity.Run) (*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if run.ID.IsZero() {
- run.ID = primitive.NewObjectID()
- }
- now := clock.NowUnixNano()
- run.CreateAt = now
- run.UpdateAt = now
- m.run = run
- return run, nil
-}
-func (m *memoryRunRepo) Update(ctx context.Context, run *entity.Run) (*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- run.UpdateAt = clock.NowUnixNano()
- m.run = run
- if run.Status == enum.RunStatusSucceeded && run.DedupeKey != "" {
- m.succeeded[run.TemplateType+":"+run.DedupeKey] = run
- }
- return run, nil
-}
-func (m *memoryRunRepo) UpdateIfStatus(ctx context.Context, run *entity.Run, allowed []enum.RunStatus) (*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run != nil {
- ok := false
- for _, status := range allowed {
- if m.run.Status == status {
- ok = true
- break
- }
- }
- if !ok {
- return nil, errors.New("job state changed; update rejected")
- }
- }
- run.UpdateAt = clock.NowUnixNano()
- m.run = run
- if run.Status == enum.RunStatusSucceeded && run.DedupeKey != "" {
- m.succeeded[run.TemplateType+":"+run.DedupeKey] = run
- }
- m.fireRaceAfterUpdateIfStatusLocked()
- return run, nil
-}
-func (m *memoryRunRepo) UpdateIfLocked(ctx context.Context, run *entity.Run, workerID string, allowed []enum.RunStatus) (*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run != nil {
- if m.run.LockedBy != "" && m.run.LockedBy != workerID {
- return nil, errors.New("job lock held by another worker")
- }
- ok := false
- for _, status := range allowed {
- if m.run.Status == status {
- ok = true
- break
- }
- }
- if !ok {
- return nil, errors.New("job state changed; update rejected")
- }
- }
- run.UpdateAt = clock.NowUnixNano()
- m.run = run
- if run.Status == enum.RunStatusSucceeded && run.DedupeKey != "" {
- m.succeeded[run.TemplateType+":"+run.DedupeKey] = run
- }
- return run, nil
-}
-func (m *memoryRunRepo) FindByID(context.Context, string) (*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- return cloneRun(m.run), nil
-}
-func (m *memoryRunRepo) List(context.Context, domrepo.RunListFilter, int64, int64) ([]*entity.Run, int64, error) {
- return nil, 0, nil
-}
-func (m *memoryRunRepo) FindActiveByScope(context.Context, string, string, string) ([]*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run == nil {
- return nil, nil
- }
- switch m.run.Status {
- case enum.RunStatusPending, enum.RunStatusQueued, enum.RunStatusRunning, enum.RunStatusWaitingWorker, enum.RunStatusCancelRequested:
- return []*entity.Run{m.run}, nil
- default:
- return nil, nil
- }
-}
-func (m *memoryRunRepo) FindSucceededByDedupeKey(_ context.Context, templateType, dedupeKey string) (*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- return m.succeeded[templateType+":"+dedupeKey], nil
-}
-func (m *memoryRunRepo) FindPendingDue(_ context.Context, now int64, _ int64) ([]*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run == nil || m.run.Status != enum.RunStatusPending || m.run.ScheduledAt == nil {
- return nil, nil
- }
- if *m.run.ScheduledAt <= now {
- return []*entity.Run{cloneRun(m.run)}, nil
- }
- return nil, nil
-}
-func (m *memoryRunRepo) FindCancelRequestedBefore(_ context.Context, before int64, _ int64) ([]*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run == nil || m.run.Status != enum.RunStatusCancelRequested || m.run.CancelRequestedAt == nil {
- return nil, nil
- }
- if *m.run.CancelRequestedAt <= before {
- snapshot := cloneRun(m.run)
- m.fireRaceAfterFindLocked()
- return []*entity.Run{snapshot}, nil
- }
- return nil, nil
-}
-func (m *memoryRunRepo) FindQueuedWithLock(_ context.Context, _ int64) ([]*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run == nil || strings.TrimSpace(m.run.LockedBy) == "" {
- return nil, nil
- }
- if m.run.Status != enum.RunStatusPending && m.run.Status != enum.RunStatusQueued {
- return nil, nil
- }
- return []*entity.Run{cloneRun(m.run)}, nil
-}
-
-func (m *memoryRunRepo) FindRunningTimedOut(_ context.Context, now int64, _ int64) ([]*entity.Run, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.run == nil || m.run.LockedUntil == nil {
- return nil, nil
- }
- if m.run.Status != enum.RunStatusRunning && m.run.Status != enum.RunStatusWaitingWorker {
- return nil, nil
- }
- if *m.run.LockedUntil <= now {
- snapshot := cloneRun(m.run)
- m.fireRaceAfterFindLocked()
- return []*entity.Run{snapshot}, nil
- }
- return nil, nil
-}
-
-type memoryScheduleRepo struct {
- mu sync.Mutex
- schedules []*entity.Schedule
-}
-
-func (m *memoryScheduleRepo) EnsureIndexes(context.Context) error { return nil }
-func (m *memoryScheduleRepo) Create(_ context.Context, schedule *entity.Schedule) (*entity.Schedule, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if schedule.ID.IsZero() {
- schedule.ID = primitive.NewObjectID()
- }
- now := clock.NowUnixNano()
- schedule.CreateAt = now
- schedule.UpdateAt = now
- m.schedules = append(m.schedules, schedule)
- return schedule, nil
-}
-func (m *memoryScheduleRepo) Update(_ context.Context, schedule *entity.Schedule) (*entity.Schedule, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- schedule.UpdateAt = clock.NowUnixNano()
- for i, item := range m.schedules {
- if item.ID == schedule.ID {
- m.schedules[i] = schedule
- return schedule, nil
- }
- }
- m.schedules = append(m.schedules, schedule)
- return schedule, nil
-}
-func (m *memoryScheduleRepo) FindByID(_ context.Context, id string) (*entity.Schedule, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- objectID, _ := primitive.ObjectIDFromHex(id)
- for _, item := range m.schedules {
- if item.ID == objectID {
- return item, nil
- }
- }
- return nil, nil
-}
-func (m *memoryScheduleRepo) Delete(_ context.Context, id string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- objectID, err := primitive.ObjectIDFromHex(id)
- if err != nil {
- return err
- }
- for i, item := range m.schedules {
- if item.ID == objectID {
- m.schedules = append(m.schedules[:i], m.schedules[i+1:]...)
- return nil
- }
- }
- return nil
-}
-func (m *memoryScheduleRepo) List(_ context.Context, _ domrepo.ScheduleListFilter, _, _ int64) ([]*entity.Schedule, int64, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- return m.schedules, int64(len(m.schedules)), nil
-}
-func (m *memoryScheduleRepo) FindDue(_ context.Context, now int64, _ int64) ([]*entity.Schedule, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- var due []*entity.Schedule
- for _, item := range m.schedules {
- if item.Enabled && item.NextRunAt <= now {
- due = append(due, item)
- }
- }
- return due, nil
-}
-
-type memoryEventRepo struct {
- events []*entity.Event
-}
-
-func (m *memoryEventRepo) EnsureIndexes(context.Context) error { return nil }
-func (m *memoryEventRepo) Append(_ context.Context, event *entity.Event) error {
- m.events = append(m.events, event)
- return nil
-}
-func (m *memoryEventRepo) ListByJobID(_ context.Context, jobID string, limit int64) ([]*entity.Event, error) {
- if limit <= 0 {
- limit = 50
- }
- var out []*entity.Event
- for _, event := range m.events {
- if event.JobID == jobID {
- out = append(out, event)
- }
- }
- if int64(len(out)) > limit {
- out = out[:limit]
- }
- return out, nil
-}
-
-type memoryQueueRepo struct {
- mu sync.Mutex
- queues map[string][]string
- cancel map[string]string
- locks map[string]string
- dedupe map[string]string
- schedulerLock string
-}
-
-func newMemoryQueueRepo() *memoryQueueRepo {
- return &memoryQueueRepo{
- queues: map[string][]string{},
- cancel: map[string]string{},
- locks: map[string]string{},
- dedupe: map[string]string{},
- }
-}
-
-func (m *memoryQueueRepo) Enqueue(_ context.Context, workerType, jobID string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.queues[workerType] = append(m.queues[workerType], jobID)
- return nil
-}
-func (m *memoryQueueRepo) Dequeue(context.Context, string, int) (string, error) { return "", nil }
-func (m *memoryQueueRepo) RemoveJob(_ context.Context, workerType, jobID string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- items := m.queues[workerType]
- filtered := make([]string, 0, len(items))
- for _, item := range items {
- if item != jobID {
- filtered = append(filtered, item)
- }
- }
- m.queues[workerType] = filtered
- return nil
-}
-func (m *memoryQueueRepo) RemoveOneJob(_ context.Context, workerType, jobID string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- items := m.queues[workerType]
- for i, item := range items {
- if item == jobID {
- m.queues[workerType] = append(items[:i], items[i+1:]...)
- return nil
- }
- }
- return nil
-}
-func (m *memoryQueueRepo) SetCancelSignal(_ context.Context, jobID, reason string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.cancel[jobID] = reason
- return nil
-}
-func (m *memoryQueueRepo) GetCancelSignal(_ context.Context, jobID string) (bool, string, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- reason, ok := m.cancel[jobID]
- return ok, reason, nil
-}
-func (m *memoryQueueRepo) ClearCancelSignal(_ context.Context, jobID string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- delete(m.cancel, jobID)
- return nil
-}
-func (m *memoryQueueRepo) TryLock(_ context.Context, jobID, workerID string, _ int) (bool, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if current, ok := m.locks[jobID]; ok && current != workerID {
- return false, nil
- }
- m.locks[jobID] = workerID
- return true, nil
-}
-func (m *memoryQueueRepo) RefreshLock(_ context.Context, jobID, workerID string, _ int) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if current, ok := m.locks[jobID]; ok && current == workerID {
- return nil
- }
- return errors.New("job lock is not held by worker")
-}
-func (m *memoryQueueRepo) ReleaseLock(_ context.Context, jobID, workerID string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if current, ok := m.locks[jobID]; ok && current == workerID {
- delete(m.locks, jobID)
- }
- return nil
-}
-func (m *memoryQueueRepo) TryAcquireDedupe(_ context.Context, templateType, dedupeHash, jobID string, _ int) (bool, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- key := templateType + ":" + dedupeHash
- if _, ok := m.dedupe[key]; ok {
- return false, nil
- }
- m.dedupe[key] = jobID
- return true, nil
-}
-func (m *memoryQueueRepo) ReleaseDedupe(_ context.Context, templateType, dedupeHash string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- delete(m.dedupe, templateType+":"+dedupeHash)
- return nil
-}
-func (m *memoryQueueRepo) TrySchedulerLock(_ context.Context, holder string, _ int) (bool, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.schedulerLock != "" && m.schedulerLock != holder {
- return false, nil
- }
- m.schedulerLock = holder
- return true, nil
-}
-func (m *memoryQueueRepo) ReleaseSchedulerLock(_ context.Context, holder string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.schedulerLock == holder {
- m.schedulerLock = ""
- }
- return nil
-}
-
-func (m *memoryQueueRepo) queued(workerType string) []string {
- m.mu.Lock()
- defer m.mu.Unlock()
- return append([]string(nil), m.queues[workerType]...)
-}
-
-func testUseCase(template *entity.Template, run *entity.Run) domusecase.UseCase {
- return NewUseCase(
- &memoryTemplateRepo{template: template},
- newMemoryRunRepo(run),
- &memoryScheduleRepo{},
- &memoryEventRepo{},
- newMemoryQueueRepo(),
- )
-}
-
-func testUseCaseFull(template *entity.Template, runs *memoryRunRepo, schedules *memoryScheduleRepo, queue *memoryQueueRepo) domusecase.UseCase {
- if schedules == nil {
- schedules = &memoryScheduleRepo{}
- }
- if queue == nil {
- queue = newMemoryQueueRepo()
- }
- return NewUseCase(
- &memoryTemplateRepo{template: template},
- runs,
- schedules,
- &memoryEventRepo{},
- queue,
- )
-}
-
-func cloneRun(run *entity.Run) *entity.Run {
- if run == nil {
- return nil
- }
- out := *run
- if run.Progress.Steps != nil {
- out.Progress.Steps = append([]entity.StepProgress(nil), run.Progress.Steps...)
- }
- if run.Payload != nil {
- out.Payload = map[string]any{}
- for k, v := range run.Payload {
- out.Payload[k] = v
- }
- }
- if run.Result != nil {
- out.Result = map[string]any{}
- for k, v := range run.Result {
- out.Result[k] = v
- }
- }
- return &out
-}
diff --git a/old/backend/internal/model/job/usecase/usecase.go b/old/backend/internal/model/job/usecase/usecase.go
deleted file mode 100644
index d6aa762..0000000
--- a/old/backend/internal/model/job/usecase/usecase.go
+++ /dev/null
@@ -1,935 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domrepo "haixun-backend/internal/model/job/domain/repository"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-const (
- demoTemplateType = "demo_long_task"
- style8DTemplateType = "style-8d"
- expandGraphTemplateType = "expand-graph"
- placementScanTemplateType = "placement-scan"
- scanViralTemplateType = "scan-viral"
- generateOutreachDraftTemplateType = "generate-outreach-draft"
- generateTopicMatrixTemplateType = "generate-topic-matrix"
- generateFormulaDraftTemplateType = "generate-formula-draft"
- generateRewriteDraftTemplateType = "generate-rewrite-draft"
- refreshThreadsTokenTemplateType = "refresh-threads-token"
- publishAnalyticsTemplateType = "publish-analytics"
- workerTypeDemo = "go-demo"
- workerTypeExpandGraph = "go-expand-graph"
- workerTypePlacementScan = "go-placement-scan"
- workerTypeScanViral = "go-scan-viral"
- workerTypeGenerateOutreachDraft = "go-generate-outreach-draft"
- workerTypeGenerateTopicMatrix = "go-generate-topic-matrix"
- workerTypeGenerateFormulaDraft = "go-generate-formula-draft"
- workerTypeGenerateRewriteDraft = "go-generate-rewrite-draft"
- workerTypeRefreshThreadsToken = "go-refresh-threads-token"
- workerTypePublishAnalytics = "go-publish-analytics"
- style8DWorkerType = "node-style-8d"
-)
-
-type UseCase = domusecase.UseCase
-
-type jobUseCase struct {
- templates domrepo.TemplateRepository
- runs domrepo.RunRepository
- schedules domrepo.ScheduleRepository
- events domrepo.EventRepository
- queue domrepo.QueueRepository
-}
-
-func NewUseCase(
- templates domrepo.TemplateRepository,
- runs domrepo.RunRepository,
- schedules domrepo.ScheduleRepository,
- events domrepo.EventRepository,
- queue domrepo.QueueRepository,
-) domusecase.UseCase {
- return &jobUseCase{
- templates: templates,
- runs: runs,
- schedules: schedules,
- events: events,
- queue: queue,
- }
-}
-
-func (u *jobUseCase) ListTemplates(ctx context.Context) ([]*entity.Template, error) {
- return u.templates.List(ctx)
-}
-
-func (u *jobUseCase) GetTemplate(ctx context.Context, templateType string) (*entity.Template, error) {
- return u.templates.FindByType(ctx, templateType)
-}
-
-func (u *jobUseCase) EnsureDemoTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, demoTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureStyle8DTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, style8DTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureExpandGraphTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, expandGraphTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsurePlacementScanTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, placementScanTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureScanViralTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, scanViralTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureGenerateOutreachDraftTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, generateOutreachDraftTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureGenerateTopicMatrixTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, generateTopicMatrixTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureGenerateFormulaDraftTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, generateFormulaDraftTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureGenerateRewriteDraftTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, generateRewriteDraftTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsureRefreshThreadsTokenTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, refreshThreadsTokenTemplate())
- return err
-}
-
-func (u *jobUseCase) EnsurePublishAnalyticsTemplate(ctx context.Context) error {
- _, err := u.templates.Upsert(ctx, publishAnalyticsTemplate())
- return err
-}
-
-func generateTopicMatrixTemplate() *entity.Template {
- return &entity.Template{
- Type: generateTopicMatrixTemplateType,
- Version: 1,
- Name: "Generate Topic Matrix",
- Description: "LLM topic matrix drafts for persona copy missions",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id", "topic"},
- TimeoutSeconds: 600,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "topic_matrix_generate", Name: "Topic matrix generate", WorkerType: workerTypeGenerateTopicMatrix, TimeoutSeconds: 600, Cancelable: true},
- },
- }
-}
-
-func generateFormulaDraftTemplate() *entity.Template {
- return &entity.Template{
- Type: generateFormulaDraftTemplateType,
- Version: 1,
- Name: "Generate Formula Draft",
- Description: "LLM formula-based copy draft for persona rewrite flow",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id", "formula_id", "topic"},
- TimeoutSeconds: 600,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "formula_draft_generate", Name: "Formula draft generate", WorkerType: workerTypeGenerateFormulaDraft, TimeoutSeconds: 600, Cancelable: true},
- },
- }
-}
-
-func generateRewriteDraftTemplate() *entity.Template {
- return &entity.Template{
- Type: generateRewriteDraftTemplateType,
- Version: 1,
- Name: "Generate Rewrite Draft",
- Description: "Analyze pasted reference post and generate persona rewrite draft",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
- DedupeKeys: []string{},
- TimeoutSeconds: 900,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 3,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "rewrite_draft_generate", Name: "Rewrite draft generate", WorkerType: workerTypeGenerateRewriteDraft, TimeoutSeconds: 900, Cancelable: true},
- },
- }
-}
-
-func generateOutreachDraftTemplate() *entity.Template {
- return &entity.Template{
- Type: generateOutreachDraftTemplateType,
- Version: 1,
- Name: "Generate Outreach Draft",
- Description: "LLM outreach reply drafts for a placement scan post",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
- DedupeKeys: []string{"scan_post_id"},
- TimeoutSeconds: 600,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "outreach_draft_generate", Name: "Outreach draft generate", WorkerType: workerTypeGenerateOutreachDraft, TimeoutSeconds: 600, Cancelable: true},
- },
- }
-}
-
-func refreshThreadsTokenTemplate() *entity.Template {
- return &entity.Template{
- Type: refreshThreadsTokenTemplateType,
- Version: 1,
- Name: "Refresh Threads Token",
- Description: "Refresh Threads API access token before expiry",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
- DedupeKeys: []string{"account_id"},
- TimeoutSeconds: 120,
- CancelPolicy: entity.CancelPolicy{
- Supported: false,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 3,
- BackoffSeconds: []int{30, 120, 300},
- },
- Steps: []entity.TemplateStep{
- {ID: "refresh", Name: "Refresh token", WorkerType: workerTypeRefreshThreadsToken, TimeoutSeconds: 120, Cancelable: false},
- },
- }
-}
-
-func publishAnalyticsTemplate() *entity.Template {
- return &entity.Template{
- Type: publishAnalyticsTemplateType,
- Version: 1,
- Name: "Publish Analytics Check",
- Description: "Check Threads API insights for a published post",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyAllowParallel),
- DedupeKeys: []string{"media_id", "checkpoint"},
- TimeoutSeconds: 120,
- CancelPolicy: entity.CancelPolicy{
- Supported: false,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 2,
- BackoffSeconds: []int{60},
- },
- Steps: []entity.TemplateStep{
- {ID: "check", Name: "Check analytics", WorkerType: workerTypePublishAnalytics, TimeoutSeconds: 120, Cancelable: false},
- },
- }
-}
-
-func scanViralTemplate() *entity.Template {
- return &entity.Template{
- Type: scanViralTemplateType,
- Version: 1,
- Name: "Viral Threads Scan",
- Description: "Keyword crawl for copy-ninja viral candidates (Flow A)",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id"},
- TimeoutSeconds: 600,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "viral_crawl", Name: "Viral keyword crawl", WorkerType: workerTypeScanViral, TimeoutSeconds: 600, Cancelable: true},
- },
- }
-}
-
-func placementScanTemplate() *entity.Template {
- return &entity.Template{
- Type: placementScanTemplateType,
- Version: 1,
- Name: "Placement Dual-Track Scan",
- Description: "Brave/Threads API dual-track crawl for selected knowledge graph tags",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id"},
- TimeoutSeconds: 7200,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "crawl", Name: "Dual-track crawl", WorkerType: workerTypePlacementScan, TimeoutSeconds: 7200, Cancelable: true},
- },
- }
-}
-
-func expandGraphTemplate() *entity.Template {
- return &entity.Template{
- Type: expandGraphTemplateType,
- Version: 1,
- Name: "Topic Knowledge Graph Expand",
- Description: "Brave knowledge_expand + AI synthesis for placement research tags",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id", "seed_query"},
- TimeoutSeconds: 600,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "expand", Name: "Expand knowledge graph", WorkerType: workerTypeExpandGraph, TimeoutSeconds: 600, Cancelable: true},
- },
- }
-}
-
-func style8DTemplate() *entity.Template {
- return &entity.Template{
- Type: style8DTemplateType,
- Version: 1,
- Name: "Threads 8D Style Analysis",
- Description: "Scrape benchmark posts and analyze D1-D8 style profile for a Threads account",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id", "benchmark_username"},
- TimeoutSeconds: 480,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 1,
- BackoffSeconds: []int{},
- },
- Steps: []entity.TemplateStep{
- {ID: "session", Name: "Prepare public profile crawl", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: true},
- {ID: "samples", Name: "Fetch public posts", WorkerType: style8DWorkerType, TimeoutSeconds: 180, Cancelable: true},
- {ID: "style", Name: "AI 8D analysis", WorkerType: style8DWorkerType, TimeoutSeconds: 240, Cancelable: true},
- {ID: "store", Name: "Save persona strategy", WorkerType: style8DWorkerType, TimeoutSeconds: 60, Cancelable: false},
- },
- }
-}
-
-func demoTemplate() *entity.Template {
- return &entity.Template{
- Type: demoTemplateType,
- Version: 1,
- Name: "Demo Long Task",
- Description: "Demonstrates job progress, cancel, retry, and worker cooperative stop",
- Enabled: true,
- Repeatable: true,
- ConcurrencyPolicy: string(enum.ConcurrencyRejectSameScope),
- DedupeKeys: []string{"scope_id"},
- TimeoutSeconds: 600,
- CancelPolicy: entity.CancelPolicy{
- Supported: true,
- Mode: "cooperative",
- GraceSeconds: 30,
- },
- RetryPolicy: entity.RetryPolicy{
- MaxAttempts: 2,
- BackoffSeconds: []int{30, 120},
- },
- Steps: []entity.TemplateStep{
- {ID: "prepare", Name: "Prepare data", WorkerType: workerTypeDemo, TimeoutSeconds: 60, Cancelable: true},
- {ID: "execute", Name: "Execute task", WorkerType: workerTypeDemo, TimeoutSeconds: 300, Cancelable: true},
- {ID: "finalize", Name: "Finalize result", WorkerType: workerTypeDemo, TimeoutSeconds: 30, Cancelable: false},
- },
- }
-}
-
-func (u *jobUseCase) CreateRun(ctx context.Context, req domusecase.CreateRunRequest) (*entity.Run, error) {
- templateType := strings.TrimSpace(req.TemplateType)
- scope := strings.TrimSpace(req.Scope)
- scopeID := strings.TrimSpace(req.ScopeID)
- if templateType == "" || scope == "" || scopeID == "" {
- return nil, app.For(code.Job).InputMissingRequired("template_type, scope, and scope_id are required")
- }
-
- template, err := u.templates.FindByType(ctx, templateType)
- if err != nil {
- return nil, err
- }
- if !template.Enabled {
- return nil, app.For(code.Job).ResInvalidState("job template is disabled")
- }
-
- if err := u.enforceConcurrency(ctx, template, scope, scopeID); err != nil {
- return nil, err
- }
-
- dedupeKey := buildDedupeKey(template, scope, scopeID, req.Payload)
- if err := u.enforceDedupe(ctx, template, dedupeKey); err != nil {
- return nil, err
- }
-
- tenantID, ownerUID := extractRunActor(req)
- run := &entity.Run{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- TemplateType: template.Type,
- TemplateVersion: template.Version,
- Scope: scope,
- ScopeID: scopeID,
- Status: enum.RunStatusPending,
- Phase: firstPhaseFromTemplate(template),
- WorkerType: workerTypeFromTemplate(template),
- Payload: req.Payload,
- Progress: entity.RunProgress{
- Summary: "waiting for worker",
- Percentage: 0,
- Steps: buildStepProgress(template),
- },
- Attempt: 1,
- MaxAttempts: maxAttemptsOrDefault(template.RetryPolicy.MaxAttempts),
- DedupeKey: dedupeKey,
- ScheduledAt: req.ScheduledAt,
- }
-
- created, err := u.runs.Create(ctx, run)
- if err != nil {
- return nil, err
- }
- jobID := created.ID.Hex()
- if err := u.acquireDedupeLease(ctx, template, dedupeKey, jobID); err != nil {
- created.Status = enum.RunStatusCancelled
- _, _ = u.runs.Update(ctx, created)
- return nil, err
- }
-
- return u.enqueueRun(ctx, created)
-}
-
-func (u *jobUseCase) enforceConcurrency(ctx context.Context, template *entity.Template, scope, scopeID string) error {
- active, err := u.runs.FindActiveByScope(ctx, template.Type, scope, scopeID)
- if err != nil {
- return err
- }
- if len(active) == 0 {
- return nil
- }
-
- switch enum.ConcurrencyPolicy(template.ConcurrencyPolicy) {
- case enum.ConcurrencyAllowParallel:
- return nil
- case enum.ConcurrencyRejectSameScope:
- return app.For(code.Job).ResInvalidState("此範圍已有進行中的任務,請等待任務結束(含取消完成)後再試")
- case enum.ConcurrencyReplaceExisting:
- for _, run := range active {
- if _, err := u.RequestCancel(ctx, domusecase.CancelRunRequest{
- JobID: run.ID.Hex(),
- Reason: "replaced by new job",
- }); err != nil {
- return err
- }
- }
- return nil
- default:
- return app.For(code.Job).ResInvalidState("此範圍已有進行中的任務,請等待任務結束(含取消完成)後再試")
- }
-}
-
-func (u *jobUseCase) GetRun(ctx context.Context, jobID string) (*entity.Run, error) {
- return u.runs.FindByID(ctx, jobID)
-}
-
-func (u *jobUseCase) ListRuns(ctx context.Context, filter domrepo.RunListFilter, page, pageSize int64) ([]*entity.Run, int64, int64, int64, int64, error) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 20
- }
- if pageSize > 200 {
- pageSize = 200
- }
- offset := (page - 1) * pageSize
- items, total, err := u.runs.List(ctx, filter, offset, pageSize)
- if err != nil {
- return nil, 0, 0, 0, 0, err
- }
- totalPages := int64(0)
- if total > 0 {
- totalPages = (total + pageSize - 1) / pageSize
- }
- return items, total, page, pageSize, totalPages, nil
-}
-
-func (u *jobUseCase) RequestCancel(ctx context.Context, req domusecase.CancelRunRequest) (*entity.Run, error) {
- jobID := strings.TrimSpace(req.JobID)
- if jobID == "" {
- return nil, app.For(code.Job).InputMissingRequired("job id is required")
- }
-
- run, err := u.runs.FindByID(ctx, jobID)
- if err != nil {
- return nil, err
- }
- if !run.Status.IsCancellable() {
- return nil, app.For(code.Job).ResInvalidState("job cannot be cancelled in current status")
- }
-
- template, err := u.templates.FindByType(ctx, run.TemplateType)
- if err != nil {
- return nil, err
- }
-
- reason := strings.TrimSpace(req.Reason)
- if reason == "" {
- reason = "user requested cancel"
- }
- now := clock.NowUnixNano()
- fromStatus := string(run.Status)
-
- switch run.Status {
- case enum.RunStatusPending, enum.RunStatusQueued:
- // A pending/queued run has no worker executing it yet, so there is
- // nothing to cooperatively interrupt: cancelling is always safe here
- // regardless of the template's CancelPolicy (that policy only governs
- // interrupting an in-flight worker below). Gating this on
- // CancelPolicy.Supported used to make e.g. a not-yet-started
- // publish-analytics checkpoint job permanently uncancellable, even
- // though it was just sitting in the queue.
- _ = u.queue.RemoveJob(ctx, run.WorkerType, jobID)
- run.Status = enum.RunStatusCancelled
- run.CancelReason = reason
- run.CompletedAt = &now
- updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{
- enum.RunStatusPending,
- enum.RunStatusQueued,
- })
- if err != nil {
- return nil, err
- }
- u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusCancelled), "job cancelled before execution", map[string]any{"reason": reason})
- return updated, nil
-
- case enum.RunStatusRunning, enum.RunStatusWaitingWorker:
- if !template.CancelPolicy.Supported {
- return nil, app.For(code.Job).ResInvalidState("job template does not support cancel")
- }
- run.Status = enum.RunStatusCancelRequested
- run.CancelRequestedAt = &now
- run.CancelReason = reason
- updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{
- enum.RunStatusRunning,
- enum.RunStatusWaitingWorker,
- })
- if err != nil {
- return nil, err
- }
- // Poke running worker via Redis cancel signal.
- if err := u.queue.SetCancelSignal(ctx, jobID, reason); err != nil {
- return nil, err
- }
- _ = u.appendEvent(ctx, jobID, "cancel_requested", fromStatus, string(enum.RunStatusCancelRequested), "cancel signal sent to worker", map[string]any{"reason": reason})
- return updated, nil
-
- default:
- return nil, app.For(code.Job).ResInvalidState("job cannot be cancelled in current status")
- }
-}
-
-func (u *jobUseCase) RetryRun(ctx context.Context, jobID string) (*entity.Run, error) {
- run, err := u.runs.FindByID(ctx, jobID)
- if err != nil {
- return nil, err
- }
- if run.Status != enum.RunStatusFailed &&
- run.Status != enum.RunStatusCancelled &&
- run.Status != enum.RunStatusExpired {
- return nil, app.For(code.Job).ResInvalidState("only failed, cancelled, or expired jobs can be retried")
- }
-
- fromStatus := string(run.Status)
- template, err := u.templates.FindByType(ctx, run.TemplateType)
- if err != nil {
- return nil, err
- }
- if err := u.acquireDedupeLease(ctx, template, run.DedupeKey, jobID); err != nil {
- return nil, err
- }
-
- run.Status = enum.RunStatusPending
- run.Progress.Steps = prepareStepsForRetry(run.Progress.Steps)
- run.Phase = firstResumablePhase(run.Progress.Steps)
- run.Error = ""
- run.Result = nil
- run.Attempt++
- run.LockedBy = ""
- run.LockedUntil = nil
- run.CancelRequestedAt = nil
- run.CancelReason = ""
- run.StartedAt = nil
- run.CompletedAt = nil
- run.Progress.Summary = "waiting for retry"
- run.Progress.Percentage = calcProgressPercentage(run.Progress.Steps)
-
- run.ScheduledAt = nil
- updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{
- enum.RunStatusFailed,
- enum.RunStatusCancelled,
- enum.RunStatusExpired,
- })
- if err != nil {
- u.releaseDedupe(ctx, run)
- return nil, err
- }
-
- backoff := retryBackoffSeconds(template, updated.Attempt)
- if backoff > 0 {
- scheduled := clock.AddSecondsFromNow(backoff)
- updated.ScheduledAt = &scheduled
- rescheduled, err := u.runs.UpdateIfStatus(ctx, updated, []enum.RunStatus{enum.RunStatusPending})
- if err != nil {
- u.releaseDedupe(ctx, updated)
- return nil, err
- }
- updated = rescheduled
- _ = u.appendEvent(ctx, jobID, "retried", fromStatus, string(enum.RunStatusPending), "job scheduled for retry", map[string]any{"attempt": updated.Attempt, "backoff_seconds": backoff})
- return updated, nil
- }
-
- updated, err = u.enqueueRun(ctx, updated)
- if err != nil {
- u.releaseDedupe(ctx, updated)
- return nil, err
- }
- _ = u.appendEvent(ctx, jobID, "retried", fromStatus, string(enum.RunStatusQueued), "job requeued", map[string]any{"attempt": updated.Attempt})
- return updated, nil
-}
-
-func (u *jobUseCase) ClaimNext(ctx context.Context, req domusecase.ClaimNextRequest) (*entity.Run, error) {
- workerType := strings.TrimSpace(req.WorkerType)
- workerID := strings.TrimSpace(req.WorkerID)
- if workerType == "" || workerID == "" {
- return nil, app.For(code.Job).InputMissingRequired("worker_type and worker_id are required")
- }
-
- for {
- jobID, err := u.queue.Dequeue(ctx, workerType, 2)
- if err != nil {
- return nil, err
- }
- if jobID == "" {
- return nil, nil
- }
-
- run, err := u.runs.FindByID(ctx, jobID)
- if err != nil {
- continue
- }
- if run.Status.IsTerminal() || run.Status == enum.RunStatusCancelRequested {
- continue
- }
-
- ok, err := u.queue.TryLock(ctx, jobID, workerID, 600)
- if err != nil {
- return nil, err
- }
- if !ok {
- _ = u.queue.Enqueue(ctx, workerType, jobID)
- continue
- }
-
- cancelled, _, err := u.queue.GetCancelSignal(ctx, jobID)
- if err != nil {
- _ = u.queue.ReleaseLock(ctx, jobID, workerID)
- return nil, err
- }
- if cancelled || run.Status == enum.RunStatusCancelRequested {
- _ = u.queue.ReleaseLock(ctx, jobID, workerID)
- continue
- }
-
- now := clock.NowUnixNano()
- fromStatus := string(run.Status)
- lockUntil := now + clock.SecondsToNanos(600)
- run.Status = enum.RunStatusRunning
- run.LockedBy = workerID
- run.LockedUntil = &lockUntil
- run.StartedAt = &now
- run.Progress.Summary = "worker claimed job"
-
- updated, err := u.runs.UpdateIfStatus(ctx, run, []enum.RunStatus{
- enum.RunStatusPending,
- enum.RunStatusQueued,
- })
- if err != nil {
- _ = u.queue.ReleaseLock(ctx, jobID, workerID)
- fresh, findErr := u.runs.FindByID(ctx, jobID)
- if findErr == nil && fresh != nil &&
- (fresh.Status == enum.RunStatusPending || fresh.Status == enum.RunStatusQueued) {
- _ = u.queue.Enqueue(ctx, workerType, jobID)
- }
- continue
- }
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusRunning), "worker claimed job", map[string]any{"worker_id": workerID})
- return updated, nil
- }
-}
-
-func (u *jobUseCase) RefreshRunLock(ctx context.Context, jobID, workerID string, ttlSeconds int) error {
- jobID = strings.TrimSpace(jobID)
- workerID = strings.TrimSpace(workerID)
- if jobID == "" || workerID == "" {
- return app.For(code.Job).InputMissingRequired("job id and worker id are required")
- }
- if ttlSeconds <= 0 {
- ttlSeconds = 300
- }
- if err := u.queue.RefreshLock(ctx, jobID, workerID, ttlSeconds); err != nil {
- return err
- }
- run, err := u.runs.FindByID(ctx, jobID)
- if err != nil {
- return err
- }
- if run.LockedBy != workerID {
- return app.For(code.Job).ResInvalidState("job is locked by another worker")
- }
- lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(ttlSeconds)
- run.LockedUntil = &lockUntil
- _, err = u.runs.UpdateIfLocked(ctx, run, workerID, []enum.RunStatus{
- enum.RunStatusRunning,
- enum.RunStatusWaitingWorker,
- })
- return err
-}
-
-func (u *jobUseCase) IsCancelRequested(ctx context.Context, jobID string) (bool, error) {
- run, err := u.runs.FindByID(ctx, jobID)
- if err != nil {
- return false, err
- }
- if run.Status == enum.RunStatusCancelRequested || run.Status == enum.RunStatusCancelled {
- return true, nil
- }
- requested, _, err := u.queue.GetCancelSignal(ctx, jobID)
- return requested, err
-}
-
-func (u *jobUseCase) AcknowledgeCancel(ctx context.Context, req domusecase.AcknowledgeCancelRequest) (*entity.Run, error) {
- jobID := strings.TrimSpace(req.JobID)
- workerID := strings.TrimSpace(req.WorkerID)
- if jobID == "" || workerID == "" {
- return nil, app.For(code.Job).InputMissingRequired("job id and worker id are required")
- }
-
- run, err := u.runs.FindByID(ctx, jobID)
- if err != nil {
- return nil, err
- }
- if run.LockedBy != "" && run.LockedBy != workerID {
- return nil, app.For(code.Job).ResInvalidState("job is locked by another worker")
- }
-
- now := clock.NowUnixNano()
- fromStatus := string(run.Status)
- run.Status = enum.RunStatusCancelled
- run.CompletedAt = &now
- run.LockedBy = ""
- run.LockedUntil = nil
- run.Progress.Summary = "job cancelled"
-
- for i := range run.Progress.Steps {
- if run.Progress.Steps[i].Status == enum.StepStatusRunning {
- run.Progress.Steps[i].Status = enum.StepStatusCancelled
- run.Progress.Steps[i].EndedAt = &now
- }
- }
-
- updated, err := u.runs.UpdateIfLocked(ctx, run, workerID, []enum.RunStatus{
- enum.RunStatusRunning,
- enum.RunStatusCancelRequested,
- })
- if err != nil {
- return nil, err
- }
- _ = u.queue.ReleaseLock(ctx, jobID, workerID)
- _ = u.queue.ClearCancelSignal(ctx, jobID)
- u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, jobID, "status_changed", fromStatus, string(enum.RunStatusCancelled), "worker acknowledged cancel", map[string]any{"worker_id": workerID})
- return updated, nil
-}
-
-func (u *jobUseCase) UpdateProgress(ctx context.Context, req domusecase.UpdateProgressRequest) (*entity.Run, error) {
- run, err := u.runs.FindByID(ctx, req.JobID)
- if err != nil {
- return nil, err
- }
- if req.Phase != "" {
- run.Phase = req.Phase
- }
- if req.Summary != "" {
- run.Progress.Summary = req.Summary
- }
- if req.Percentage >= 0 {
- run.Progress.Percentage = req.Percentage
- }
- if len(req.Steps) > 0 {
- run.Progress.Steps = req.Steps
- }
- if strings.TrimSpace(req.WorkerID) != "" && run.LockedBy == req.WorkerID {
- lockUntil := clock.NowUnixNano() + clock.SecondsToNanos(600)
- run.LockedUntil = &lockUntil
- if err := u.queue.RefreshLock(ctx, req.JobID, req.WorkerID, 600); err != nil {
- return nil, err
- }
- }
- return u.runs.UpdateIfLocked(ctx, run, req.WorkerID, []enum.RunStatus{enum.RunStatusRunning})
-}
-
-func (u *jobUseCase) CompleteRun(ctx context.Context, req domusecase.CompleteRunRequest) (*entity.Run, error) {
- run, err := u.runs.FindByID(ctx, req.JobID)
- if err != nil {
- return nil, err
- }
- workerID := strings.TrimSpace(req.WorkerID)
- if workerID == "" {
- workerID = run.LockedBy
- }
- now := clock.NowUnixNano()
- fromStatus := string(run.Status)
- run.Status = enum.RunStatusSucceeded
- run.Result = req.Result
- run.CompletedAt = &now
- run.Progress.Summary = "job completed"
- run.Progress.Percentage = 100
- run.LockedBy = ""
- run.LockedUntil = nil
-
- updated, err := u.runs.UpdateIfLocked(ctx, run, workerID, []enum.RunStatus{enum.RunStatusRunning})
- if err != nil {
- return nil, err
- }
- _ = u.queue.ReleaseLock(ctx, req.JobID, workerID)
- _ = u.queue.ClearCancelSignal(ctx, req.JobID)
- u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, req.JobID, "status_changed", fromStatus, string(enum.RunStatusSucceeded), "job completed", nil)
- return updated, nil
-}
-
-func (u *jobUseCase) FailRun(ctx context.Context, req domusecase.FailRunRequest) (*entity.Run, error) {
- run, err := u.runs.FindByID(ctx, req.JobID)
- if err != nil {
- return nil, err
- }
- workerID := strings.TrimSpace(req.WorkerID)
- if workerID == "" {
- workerID = run.LockedBy
- }
- now := clock.NowUnixNano()
- fromStatus := string(run.Status)
- run.Status = enum.RunStatusFailed
- run.Error = req.Error
- if req.Phase != "" {
- run.Phase = req.Phase
- }
- run.CompletedAt = &now
- if strings.TrimSpace(req.Error) != "" {
- run.Progress.Summary = req.Error
- } else {
- run.Progress.Summary = "任務失敗"
- }
- run.Progress.Percentage = calcProgressPercentage(run.Progress.Steps)
- run.LockedBy = ""
- run.LockedUntil = nil
-
- updated, err := u.runs.UpdateIfLocked(ctx, run, workerID, []enum.RunStatus{
- enum.RunStatusRunning,
- enum.RunStatusCancelRequested,
- })
- if err != nil {
- return nil, err
- }
- _ = u.queue.ReleaseLock(ctx, req.JobID, workerID)
- _ = u.queue.ClearCancelSignal(ctx, req.JobID)
- u.releaseDedupe(ctx, updated)
- _ = u.appendEvent(ctx, req.JobID, "status_changed", fromStatus, string(enum.RunStatusFailed), req.Error, nil)
- return updated, nil
-}
-
-func maxAttemptsOrDefault(maxAttempts int) int {
- if maxAttempts <= 0 {
- return 1
- }
- return maxAttempts
-}
diff --git a/old/backend/internal/model/knowledge_graph/domain/entity/graph.go b/old/backend/internal/model/knowledge_graph/domain/entity/graph.go
deleted file mode 100644
index 33eeb29..0000000
--- a/old/backend/internal/model/knowledge_graph/domain/entity/graph.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package entity
-
-import (
- libkg "haixun-backend/internal/library/knowledge"
-)
-
-const CollectionName = "topic_knowledge_graphs"
-
-type Graph struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id"`
- TopicID string `bson:"topic_id,omitempty"`
- CopyMissionID string `bson:"copy_mission_id,omitempty"`
- LegacyPersonaID string `bson:"persona_id,omitempty"`
- Seed string `bson:"seed"`
- Nodes []libkg.Node `bson:"nodes"`
- Edges []libkg.Edge `bson:"edges"`
- BraveSources []libkg.BraveSource `bson:"brave_sources"`
- ExpandStrategy string `bson:"expand_strategy,omitempty"`
- PainTagCount int `bson:"pain_tag_count"`
- GeneratedAt int64 `bson:"generated_at"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/knowledge_graph/domain/repository/repository.go b/old/backend/internal/model/knowledge_graph/domain/repository/repository.go
deleted file mode 100644
index 9e40d92..0000000
--- a/old/backend/internal/model/knowledge_graph/domain/repository/repository.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package repository
-
-import (
- "context"
-
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/model/knowledge_graph/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- UpsertByBrand(ctx context.Context, graph *entity.Graph) (*entity.Graph, error)
- UpsertByTopic(ctx context.Context, graph *entity.Graph) (*entity.Graph, error)
- FindByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Graph, error)
- FindByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Graph, error)
- UpdateNodes(ctx context.Context, tenantID, ownerUID, brandID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error)
- UpdateNodesByTopic(ctx context.Context, tenantID, ownerUID, topicID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error)
- UpsertByCopyMission(ctx context.Context, graph *entity.Graph) (*entity.Graph, error)
- FindByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*entity.Graph, error)
- UpdateNodesByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string, nodes []libkg.Node, painTagCount int) (*entity.Graph, error)
- AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
-}
diff --git a/old/backend/internal/model/knowledge_graph/domain/usecase/usecase.go b/old/backend/internal/model/knowledge_graph/domain/usecase/usecase.go
deleted file mode 100644
index c7aebcf..0000000
--- a/old/backend/internal/model/knowledge_graph/domain/usecase/usecase.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package usecase
-
-import (
- "context"
-
- libkg "haixun-backend/internal/library/knowledge"
-)
-
-type GraphSummary struct {
- ID string
- BrandID string
- TopicID string
- CopyMissionID string
- Seed string
- Nodes []libkg.Node
- Edges []libkg.Edge
- BraveSources []libkg.BraveSource
- ExpandStrategy string
- PainTagCount int
- GeneratedAt int64
- CreateAt int64
- UpdateAt int64
-}
-
-type UpsertRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- CopyMissionID string
- Seed string
- Nodes []libkg.Node
- Edges []libkg.Edge
- BraveSources []libkg.BraveSource
- ExpandStrategy string
- PainTagCount int
- GeneratedAt int64
-}
-
-type NodeUpdate struct {
- NodeID string
- SelectedForScan *bool
- RelevanceTags []string
- RelevanceTagsSet bool
- RecencyTags []string
- RecencyTagsSet bool
-}
-
-type UpdateNodesRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- CopyMissionID string
- Updates []NodeUpdate
-}
-
-type UseCase interface {
- Get(ctx context.Context, tenantID, ownerUID, brandID string) (*GraphSummary, error)
- // brandID enables legacy fallback when graph was stored under brand_id without topic_id.
- GetByTopic(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*GraphSummary, error)
- GetByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*GraphSummary, error)
- Upsert(ctx context.Context, req UpsertRequest) (*GraphSummary, error)
- UpdateNodes(ctx context.Context, req UpdateNodesRequest) (*GraphSummary, error)
- AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
-}
diff --git a/old/backend/internal/model/knowledge_graph/repository/mongo.go b/old/backend/internal/model/knowledge_graph/repository/mongo.go
deleted file mode 100644
index 96dfdc4..0000000
--- a/old/backend/internal/model/knowledge_graph/repository/mongo.go
+++ /dev/null
@@ -1,357 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/model/knowledge_graph/domain/entity"
- domrepo "haixun-backend/internal/model/knowledge_graph/domain/repository"
-
- "github.com/google/uuid"
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}, "topic_id": bson.M{"$in": []interface{}{nil, ""}}})},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "topic_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"topic_id": bson.M{"$gt": ""}})},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "copy_mission_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"copy_mission_id": bson.M{"$gt": ""}})},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}}, Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})},
- {Keys: bson.D{{Key: "brand_id", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "topic_id", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "copy_mission_id", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "persona_id", Value: 1}, {Key: "update_at", Value: -1}}},
- })
-}
-
-func brandOwnerFilter(tenantID, ownerUID, brandID string) bson.M {
- filter := bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- }
- for k, v := range libmongo.BrandScopeFilter(brandID) {
- filter[k] = v
- }
- return filter
-}
-
-func topicOwnerFilter(tenantID, ownerUID, topicID string) bson.M {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "topic_id": strings.TrimSpace(topicID),
- }
-}
-
-func copyMissionOwnerFilter(tenantID, ownerUID, copyMissionID string) bson.M {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "copy_mission_id": strings.TrimSpace(copyMissionID),
- }
-}
-
-func (r *mongoRepository) UpsertByBrand(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if graph == nil {
- return nil, app.For(code.Brand).InputMissingRequired("graph is required")
- }
- now := clock.NowUnixNano()
- graph.UpdateAt = now
- if graph.CreateAt == 0 {
- graph.CreateAt = now
- }
- if strings.TrimSpace(graph.ID) == "" {
- graph.ID = uuid.NewString()
- }
-
- filter := brandOwnerFilter(graph.TenantID, graph.OwnerUID, graph.BrandID)
- update := bson.M{
- "$set": bson.M{
- "seed": graph.Seed,
- "nodes": graph.Nodes,
- "edges": graph.Edges,
- "brave_sources": graph.BraveSources,
- "expand_strategy": graph.ExpandStrategy,
- "pain_tag_count": graph.PainTagCount,
- "generated_at": graph.GeneratedAt,
- "update_at": graph.UpdateAt,
- "brand_id": graph.BrandID,
- },
- "$setOnInsert": bson.M{
- "_id": graph.ID,
- "tenant_id": graph.TenantID,
- "owner_uid": graph.OwnerUID,
- "create_at": graph.CreateAt,
- },
- }
- opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
- var out entity.Graph
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) UpsertByTopic(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if graph == nil || strings.TrimSpace(graph.TopicID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("topic_id is required")
- }
- now := clock.NowUnixNano()
- graph.UpdateAt = now
- if graph.CreateAt == 0 {
- graph.CreateAt = now
- }
- if strings.TrimSpace(graph.ID) == "" {
- graph.ID = uuid.NewString()
- }
-
- filter := topicOwnerFilter(graph.TenantID, graph.OwnerUID, graph.TopicID)
- update := bson.M{
- "$set": bson.M{
- "seed": graph.Seed,
- "nodes": graph.Nodes,
- "edges": graph.Edges,
- "brave_sources": graph.BraveSources,
- "expand_strategy": graph.ExpandStrategy,
- "pain_tag_count": graph.PainTagCount,
- "generated_at": graph.GeneratedAt,
- "update_at": graph.UpdateAt,
- "brand_id": graph.BrandID,
- "topic_id": graph.TopicID,
- },
- "$setOnInsert": bson.M{
- "_id": graph.ID,
- "tenant_id": graph.TenantID,
- "owner_uid": graph.OwnerUID,
- "create_at": graph.CreateAt,
- },
- }
- opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
- var out entity.Graph
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) UpsertByCopyMission(ctx context.Context, graph *entity.Graph) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if graph == nil || strings.TrimSpace(graph.CopyMissionID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("copy_mission_id is required")
- }
- now := clock.NowUnixNano()
- graph.UpdateAt = now
- if graph.CreateAt == 0 {
- graph.CreateAt = now
- }
- if strings.TrimSpace(graph.ID) == "" {
- graph.ID = uuid.NewString()
- }
-
- filter := copyMissionOwnerFilter(graph.TenantID, graph.OwnerUID, graph.CopyMissionID)
- update := bson.M{
- "$set": bson.M{
- "seed": graph.Seed,
- "nodes": graph.Nodes,
- "edges": graph.Edges,
- "brave_sources": graph.BraveSources,
- "expand_strategy": graph.ExpandStrategy,
- "pain_tag_count": graph.PainTagCount,
- "generated_at": graph.GeneratedAt,
- "update_at": graph.UpdateAt,
- "brand_id": graph.BrandID,
- "copy_mission_id": graph.CopyMissionID,
- },
- "$setOnInsert": bson.M{
- "_id": graph.ID,
- "tenant_id": graph.TenantID,
- "owner_uid": graph.OwnerUID,
- "create_at": graph.CreateAt,
- },
- }
- opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After)
- var out entity.Graph
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) FindByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- var out entity.Graph
- err := r.collection.FindOne(ctx, copyMissionOwnerFilter(tenantID, ownerUID, copyMissionID)).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) UpdateNodesByCopyMission(
- ctx context.Context,
- tenantID, ownerUID, copyMissionID string,
- nodes []libkg.Node,
- painTagCount int,
-) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- var out entity.Graph
- err := r.collection.FindOneAndUpdate(
- ctx,
- copyMissionOwnerFilter(tenantID, ownerUID, copyMissionID),
- bson.M{"$set": bson.M{
- "nodes": nodes,
- "pain_tag_count": painTagCount,
- "update_at": now,
- }},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) FindByTopic(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- var out entity.Graph
- err := r.collection.FindOne(ctx, topicOwnerFilter(tenantID, ownerUID, topicID)).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.UpdateOne(
- ctx,
- brandOwnerFilter(tenantID, ownerUID, brandID),
- bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}},
- )
- return err
-}
-
-func (r *mongoRepository) UpdateNodesByTopic(
- ctx context.Context,
- tenantID, ownerUID, topicID string,
- nodes []libkg.Node,
- painTagCount int,
-) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- var out entity.Graph
- err := r.collection.FindOneAndUpdate(
- ctx,
- topicOwnerFilter(tenantID, ownerUID, topicID),
- bson.M{"$set": bson.M{
- "nodes": nodes,
- "pain_tag_count": painTagCount,
- "update_at": now,
- }},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) FindByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- var out entity.Graph
- err := r.collection.FindOne(ctx, brandOwnerFilter(tenantID, ownerUID, brandID)).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) UpdateNodes(
- ctx context.Context,
- tenantID, ownerUID, brandID string,
- nodes []libkg.Node,
- painTagCount int,
-) (*entity.Graph, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- var out entity.Graph
- err := r.collection.FindOneAndUpdate(
- ctx,
- brandOwnerFilter(tenantID, ownerUID, brandID),
- bson.M{"$set": bson.M{
- "nodes": nodes,
- "pain_tag_count": painTagCount,
- "update_at": now,
- }},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/knowledge_graph/usecase/topic_graph.go b/old/backend/internal/model/knowledge_graph/usecase/topic_graph.go
deleted file mode 100644
index 93de925..0000000
--- a/old/backend/internal/model/knowledge_graph/usecase/topic_graph.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/knowledge_graph/domain/entity"
-)
-
-func isGraphNotFound(err error) bool {
- if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound {
- return true
- }
- return false
-}
-
-// loadTopicGraph resolves a placement-topic graph, including legacy brand-scoped graphs.
-func (u *knowledgeGraphUseCase) loadTopicGraph(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*entity.Graph, error) {
- topicID = strings.TrimSpace(topicID)
- if topicID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("topic id is required")
- }
- item, err := u.repo.FindByTopic(ctx, tenantID, ownerUID, topicID)
- if err != nil && !isGraphNotFound(err) {
- return nil, err
- }
- if item != nil {
- return item, nil
- }
- brandID = strings.TrimSpace(brandID)
- if brandID == "" {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- legacy, legacyErr := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID)
- if legacyErr != nil {
- if isGraphNotFound(legacyErr) {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- return nil, legacyErr
- }
- if legacy == nil {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- legacyTopicID := strings.TrimSpace(legacy.TopicID)
- if legacyTopicID != "" && legacyTopicID != topicID {
- return nil, app.For(code.Brand).ResNotFound("knowledge graph not found")
- }
- if legacyTopicID == "" {
- if err := u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID); err != nil {
- return nil, err
- }
- legacy.TopicID = topicID
- }
- return legacy, nil
-}
diff --git a/old/backend/internal/model/knowledge_graph/usecase/usecase.go b/old/backend/internal/model/knowledge_graph/usecase/usecase.go
deleted file mode 100644
index 32063d7..0000000
--- a/old/backend/internal/model/knowledge_graph/usecase/usecase.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/model/knowledge_graph/domain/entity"
- domrepo "haixun-backend/internal/model/knowledge_graph/domain/repository"
- domusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
-)
-
-type knowledgeGraphUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &knowledgeGraphUseCase{repo: repo}
-}
-
-func (u *knowledgeGraphUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID string) (*domusecase.GraphSummary, error) {
- if err := requireScope(tenantID, ownerUID, brandID, "", ""); err != nil {
- return nil, err
- }
- item, err := u.repo.FindByBrand(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *knowledgeGraphUseCase) GetByCopyMission(ctx context.Context, tenantID, ownerUID, copyMissionID string) (*domusecase.GraphSummary, error) {
- if err := requireScope(tenantID, ownerUID, "", "", copyMissionID); err != nil {
- return nil, err
- }
- item, err := u.repo.FindByCopyMission(ctx, tenantID, ownerUID, copyMissionID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *knowledgeGraphUseCase) GetByTopic(ctx context.Context, tenantID, ownerUID, topicID, brandID string) (*domusecase.GraphSummary, error) {
- if err := requireScope(tenantID, ownerUID, "", topicID, ""); err != nil {
- return nil, err
- }
- item, err := u.loadTopicGraph(ctx, tenantID, ownerUID, topicID, brandID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *knowledgeGraphUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.GraphSummary, error) {
- topicID := strings.TrimSpace(req.TopicID)
- brandID := strings.TrimSpace(req.BrandID)
- copyMissionID := strings.TrimSpace(req.CopyMissionID)
- if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID, copyMissionID); err != nil {
- return nil, err
- }
- seed := strings.TrimSpace(req.Seed)
- if seed == "" {
- return nil, app.For(code.Brand).InputMissingRequired("seed is required")
- }
- graph := &entity.Graph{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- BrandID: brandID,
- TopicID: topicID,
- CopyMissionID: copyMissionID,
- Seed: seed,
- Nodes: req.Nodes,
- Edges: req.Edges,
- BraveSources: req.BraveSources,
- ExpandStrategy: req.ExpandStrategy,
- PainTagCount: req.PainTagCount,
- GeneratedAt: req.GeneratedAt,
- }
- var (
- item *entity.Graph
- err error
- )
- switch {
- case copyMissionID != "":
- item, err = u.repo.UpsertByCopyMission(ctx, graph)
- case topicID != "":
- item, err = u.repo.UpsertByTopic(ctx, graph)
- default:
- item, err = u.repo.UpsertByBrand(ctx, graph)
- }
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *knowledgeGraphUseCase) UpdateNodes(ctx context.Context, req domusecase.UpdateNodesRequest) (*domusecase.GraphSummary, error) {
- topicID := strings.TrimSpace(req.TopicID)
- brandID := strings.TrimSpace(req.BrandID)
- copyMissionID := strings.TrimSpace(req.CopyMissionID)
- if err := requireScope(req.TenantID, req.OwnerUID, brandID, topicID, copyMissionID); err != nil {
- return nil, err
- }
- if len(req.Updates) == 0 {
- return nil, app.For(code.Brand).InputMissingRequired("updates is required")
- }
- var (
- current *entity.Graph
- err error
- )
- switch {
- case copyMissionID != "":
- current, err = u.repo.FindByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID)
- case topicID != "":
- current, err = u.loadTopicGraph(ctx, req.TenantID, req.OwnerUID, topicID, brandID)
- default:
- current, err = u.repo.FindByBrand(ctx, req.TenantID, req.OwnerUID, brandID)
- }
- if err != nil {
- return nil, err
- }
- changes := map[string]domusecase.NodeUpdate{}
- for _, update := range req.Updates {
- id := strings.TrimSpace(update.NodeID)
- if id == "" {
- continue
- }
- changes[id] = update
- }
- nodes := make([]libkg.Node, len(current.Nodes))
- copy(nodes, current.Nodes)
- for i := range nodes {
- update, ok := changes[nodes[i].ID]
- if !ok {
- continue
- }
- if update.SelectedForScan != nil {
- nodes[i].SelectedForScan = *update.SelectedForScan
- }
- if update.RelevanceTagsSet {
- nodes[i].PatrolRelevance = libkg.SanitizePatrolKeywordList(update.RelevanceTags)
- }
- if update.RecencyTagsSet {
- nodes[i].PatrolRecency = libkg.SanitizePatrolKeywordList(update.RecencyTags)
- }
- if update.RelevanceTagsSet || update.RecencyTagsSet {
- nodes[i].DerivedTags = libkg.DerivePatrolTagsForNode(nodes[i], libkg.PatrolTagInput{})
- }
- }
- painCount := libkg.CountPainTagCandidates(nodes)
- var item *entity.Graph
- switch {
- case copyMissionID != "":
- item, err = u.repo.UpdateNodesByCopyMission(ctx, req.TenantID, req.OwnerUID, copyMissionID, nodes, painCount)
- case topicID != "":
- item, err = u.repo.UpdateNodesByTopic(ctx, req.TenantID, req.OwnerUID, topicID, nodes, painCount)
- default:
- item, err = u.repo.UpdateNodes(ctx, req.TenantID, req.OwnerUID, brandID, nodes, painCount)
- }
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *knowledgeGraphUseCase) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
- if err := requireScope(tenantID, ownerUID, brandID, topicID, ""); err != nil {
- return err
- }
- return u.repo.AttachTopicID(ctx, tenantID, ownerUID, brandID, topicID)
-}
-
-func requireScope(tenantID, ownerUID, brandID, topicID, copyMissionID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required")
- }
- if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" && strings.TrimSpace(copyMissionID) == "" {
- return app.For(code.Brand).InputMissingRequired("brand id, topic id, or copy mission id is required")
- }
- return nil
-}
-
-func toSummary(item *entity.Graph) domusecase.GraphSummary {
- if item == nil {
- return domusecase.GraphSummary{}
- }
- return domusecase.GraphSummary{
- ID: item.ID,
- BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID),
- TopicID: strings.TrimSpace(item.TopicID),
- CopyMissionID: strings.TrimSpace(item.CopyMissionID),
- Seed: item.Seed,
- Nodes: item.Nodes,
- Edges: item.Edges,
- BraveSources: item.BraveSources,
- ExpandStrategy: item.ExpandStrategy,
- PainTagCount: item.PainTagCount,
- GeneratedAt: item.GeneratedAt,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
diff --git a/old/backend/internal/model/member/domain/entity/member.go b/old/backend/internal/model/member/domain/entity/member.go
deleted file mode 100644
index 3da6507..0000000
--- a/old/backend/internal/model/member/domain/entity/member.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package entity
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-const CollectionName = "members"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusSuspended Status = "suspended"
- StatusDeleted Status = "deleted"
-)
-
-type Origin string
-
-const (
- OriginNative Origin = "native"
- // OriginOAuth is reserved for future third-party sign-in; those accounts skip native email verification.
- OriginOAuth Origin = "oauth"
-)
-
-type Member struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- TenantID string `bson:"tenant_id"`
- UID string `bson:"uid"`
- Email string `bson:"email"`
- EmailVerified bool `bson:"email_verified"`
- EmailVerifyCode string `bson:"email_verify_code,omitempty"`
- EmailVerifyExpiresAt int64 `bson:"email_verify_expires_at,omitempty"`
- DisplayName string `bson:"display_name,omitempty"`
- Avatar string `bson:"avatar,omitempty"`
- Phone string `bson:"phone,omitempty"`
- Language string `bson:"language,omitempty"`
- Currency string `bson:"currency,omitempty"`
- Status Status `bson:"status"`
- Origin Origin `bson:"origin"`
- PasswordHash string `bson:"password_hash,omitempty"`
- Roles []string `bson:"roles,omitempty"`
- ActiveThreadsAccountID string `bson:"active_threads_account_id,omitempty"`
- BusinessEmail string `bson:"business_email,omitempty"`
- BusinessEmailVerified bool `bson:"business_email_verified"`
- BusinessPhone string `bson:"business_phone,omitempty"`
- BusinessPhoneVerified bool `bson:"business_phone_verified"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/member/domain/repository/member.go b/old/backend/internal/model/member/domain/repository/member.go
deleted file mode 100644
index 089f975..0000000
--- a/old/backend/internal/model/member/domain/repository/member.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/member/domain/entity"
-)
-
-type ProfileUpdate struct {
- DisplayName *string
- Avatar *string
- Language *string
- Currency *string
- Phone *string
- EmailVerified *bool
- Status *entity.Status
- BusinessEmail *string
- BusinessEmailVerified *bool
- BusinessPhone *string
- BusinessPhoneVerified *bool
-}
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, member *entity.Member) (*entity.Member, error)
- List(ctx context.Context, tenantID string, page, pageSize int64) ([]*entity.Member, int64, int64, int64, error)
- FindByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
- FindByEmail(ctx context.Context, tenantID, email string) (*entity.Member, error)
- UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error)
- SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
- SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
- SetEmailVerificationCode(ctx context.Context, tenantID, uid, code string, expiresAt int64) error
- ConfirmEmailVerification(ctx context.Context, tenantID, uid, code string) (*entity.Member, error)
- UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error
-}
diff --git a/old/backend/internal/model/member/domain/usecase/member.go b/old/backend/internal/model/member/domain/usecase/member.go
deleted file mode 100644
index 27e54a2..0000000
--- a/old/backend/internal/model/member/domain/usecase/member.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/member/domain/entity"
-)
-
-type RegisterRequest struct {
- TenantID string
- Email string
- Password string
- DisplayName string
- Language string
-}
-
-type LoginRequest struct {
- TenantID string
- Email string
- Password string
-}
-
-type UpdateProfileRequest struct {
- TenantID string
- UID string
- DisplayName *string
- Avatar *string
- Language *string
- Currency *string
- Phone *string
- EmailVerified *bool
- Status *string
- BusinessEmail *string
- BusinessEmailVerified *bool
- BusinessPhone *string
- BusinessPhoneVerified *bool
-}
-
-type ListMembersRequest struct {
- TenantID string
- Page int64
- PageSize int64
-}
-
-type ListMembersResult struct {
- List []*entity.Member
- Total int64
- Page int64
- PageSize int64
- TotalPages int64
-}
-
-type AuthToken struct {
- AccessToken string
- RefreshToken string
- ExpiresIn int64
- UID string
- TokenType string
-}
-
-type UseCase interface {
- Register(ctx context.Context, req RegisterRequest) (*entity.Member, *AuthToken, error)
- Login(ctx context.Context, req LoginRequest) (*entity.Member, *AuthToken, error)
- ListMembers(ctx context.Context, req ListMembersRequest) (*ListMembersResult, error)
- GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
- UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*entity.Member, error)
- UpdatePassword(ctx context.Context, tenantID, uid, password string) error
- SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
- SetEmailVerificationCode(ctx context.Context, tenantID, uid, code string, expiresAt int64) error
- VerifyEmail(ctx context.Context, tenantID, uid, code string) (*entity.Member, error)
- ResendEmailVerification(ctx context.Context, tenantID, uid string, send func(email, code string) error) error
- SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
-}
diff --git a/old/backend/internal/model/member/repository/mongo.go b/old/backend/internal/model/member/repository/mongo.go
deleted file mode 100644
index a4efa55..0000000
--- a/old/backend/internal/model/member/repository/mongo.go
+++ /dev/null
@@ -1,314 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/member/domain/entity"
- domrepo "haixun-backend/internal/model/member/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/bson/primitive"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "uid", Value: 1}}, Options: options.Index().SetUnique(true)},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "email", Value: 1}}, Options: options.Index().SetUnique(true)},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, member *entity.Member) (*entity.Member, error) {
- if r.collection == nil {
- return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- member.Email = normalizeEmail(member.Email)
- member.CreateAt = now
- member.UpdateAt = now
- if member.ID.IsZero() {
- member.ID = primitive.NewObjectID()
- }
- if member.Status == "" {
- member.Status = entity.StatusOpen
- }
- if member.Origin == "" {
- member.Origin = entity.OriginNative
- }
- _, err := r.collection.InsertOne(ctx, member)
- if mongo.IsDuplicateKeyError(err) {
- return nil, app.For(code.Member).ResConflict("member already exists")
- }
- if err != nil {
- return nil, err
- }
- return member, nil
-}
-
-func (r *mongoRepository) List(ctx context.Context, tenantID string, page, pageSize int64) ([]*entity.Member, int64, int64, int64, error) {
- if r.collection == nil {
- return nil, 0, page, pageSize, app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" {
- return nil, 0, page, pageSize, app.For(code.Member).InputMissingRequired("tenant_id is required")
- }
- page, pageSize = normalizePagination(page, pageSize)
- filter := bson.M{"tenant_id": tenantID}
- total, err := r.collection.CountDocuments(ctx, filter)
- if err != nil {
- return nil, 0, page, pageSize, err
- }
- cursor, err := r.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetSkip((page-1)*pageSize).SetLimit(pageSize))
- if err != nil {
- return nil, 0, page, pageSize, err
- }
- defer cursor.Close(ctx)
- items := make([]*entity.Member, 0)
- for cursor.Next(ctx) {
- var item entity.Member
- if err := cursor.Decode(&item); err != nil {
- return nil, 0, page, pageSize, err
- }
- items = append(items, &item)
- }
- if err := cursor.Err(); err != nil {
- return nil, 0, page, pageSize, err
- }
- return items, total, page, pageSize, nil
-}
-
-func (r *mongoRepository) FindByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) {
- return r.findOne(ctx, bson.M{"tenant_id": tenantID, "uid": uid})
-}
-
-func (r *mongoRepository) FindByEmail(ctx context.Context, tenantID, email string) (*entity.Member, error) {
- return r.findOne(ctx, bson.M{"tenant_id": tenantID, "email": normalizeEmail(email)})
-}
-
-func (r *mongoRepository) SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error {
- if r.collection == nil {
- return app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" || uid == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"tenant_id": tenantID, "uid": uid},
- bson.M{"$set": bson.M{"active_threads_account_id": strings.TrimSpace(accountID), "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Member).ResNotFound("member not found")
- }
- return nil
-}
-
-func (r *mongoRepository) SetRoles(ctx context.Context, tenantID, uid string, roles []string) error {
- if r.collection == nil {
- return app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" || uid == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"tenant_id": tenantID, "uid": uid},
- bson.M{"$set": bson.M{"roles": roles, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Member).ResNotFound("member not found")
- }
- return nil
-}
-
-func (r *mongoRepository) UpdateProfile(ctx context.Context, tenantID, uid string, update domrepo.ProfileUpdate) (*entity.Member, error) {
- if r.collection == nil {
- return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- set := bson.M{"update_at": clock.NowUnixNano()}
- if update.DisplayName != nil {
- set["display_name"] = *update.DisplayName
- }
- if update.Avatar != nil {
- set["avatar"] = *update.Avatar
- }
- if update.Language != nil {
- set["language"] = *update.Language
- }
- if update.Currency != nil {
- set["currency"] = *update.Currency
- }
- if update.Phone != nil {
- set["phone"] = *update.Phone
- }
- if update.EmailVerified != nil {
- set["email_verified"] = *update.EmailVerified
- }
- if update.Status != nil {
- set["status"] = *update.Status
- }
- if update.BusinessEmail != nil {
- set["business_email"] = *update.BusinessEmail
- }
- if update.BusinessEmailVerified != nil {
- set["business_email_verified"] = *update.BusinessEmailVerified
- }
- if update.BusinessPhone != nil {
- set["business_phone"] = *update.BusinessPhone
- }
- if update.BusinessPhoneVerified != nil {
- set["business_phone_verified"] = *update.BusinessPhoneVerified
- }
- var out entity.Member
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"tenant_id": tenantID, "uid": uid},
- bson.M{"$set": set},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Member).ResNotFound("member not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error {
- if r.collection == nil {
- return app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" || uid == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"tenant_id": tenantID, "uid": uid},
- bson.M{"$set": bson.M{"password_hash": passwordHash, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Member).ResNotFound("member not found")
- }
- return nil
-}
-
-func (r *mongoRepository) ConfirmEmailVerification(ctx context.Context, tenantID, uid, verifyCode string) (*entity.Member, error) {
- if r.collection == nil {
- return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- member, err := r.FindByUID(ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- if member.EmailVerified {
- return member, nil
- }
- normalizedCode := strings.TrimSpace(verifyCode)
- if normalizedCode == "" || member.EmailVerifyCode == "" || member.EmailVerifyCode != normalizedCode {
- return nil, app.For(code.Auth).AuthUnauthorized("invalid verification code")
- }
- if member.EmailVerifyExpiresAt > 0 && clock.NowUnixNano() > member.EmailVerifyExpiresAt {
- return nil, app.For(code.Auth).AuthUnauthorized("verification code expired")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"tenant_id": tenantID, "uid": uid, "email_verify_code": member.EmailVerifyCode},
- bson.M{
- "$set": bson.M{
- "email_verified": true,
- "update_at": clock.NowUnixNano(),
- },
- "$unset": bson.M{
- "email_verify_code": "",
- "email_verify_expires_at": "",
- },
- },
- )
- if err != nil {
- return nil, err
- }
- if res.MatchedCount == 0 {
- return nil, app.For(code.Auth).AuthUnauthorized("invalid verification code")
- }
- return r.FindByUID(ctx, tenantID, uid)
-}
-
-func (r *mongoRepository) SetEmailVerificationCode(ctx context.Context, tenantID, uid, verifyCode string, expiresAt int64) error {
- if r.collection == nil {
- return app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" || uid == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"tenant_id": tenantID, "uid": uid},
- bson.M{"$set": bson.M{"email_verify_code": verifyCode, "email_verify_expires_at": expiresAt, "email_verified": false, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Member).ResNotFound("member not found")
- }
- return nil
-}
-
-func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Member, error) {
- if r.collection == nil {
- return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")
- }
- var out entity.Member
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Member).ResNotFound("member not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func normalizeEmail(email string) string {
- return strings.ToLower(strings.TrimSpace(email))
-}
-
-func normalizePagination(page, pageSize int64) (int64, int64) {
- if page < 1 {
- page = 1
- }
- if pageSize < 1 {
- pageSize = 20
- }
- if pageSize > 100 {
- pageSize = 100
- }
- return page, pageSize
-}
diff --git a/old/backend/internal/model/member/usecase/usecase.go b/old/backend/internal/model/member/usecase/usecase.go
deleted file mode 100644
index 336a46c..0000000
--- a/old/backend/internal/model/member/usecase/usecase.go
+++ /dev/null
@@ -1,280 +0,0 @@
-package usecase
-
-import (
- "context"
- "crypto/rand"
- "fmt"
- "math/big"
- "strings"
- "time"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- authusecase "haixun-backend/internal/model/auth/domain/usecase"
- "haixun-backend/internal/model/member/domain/entity"
- domrepo "haixun-backend/internal/model/member/domain/repository"
- domusecase "haixun-backend/internal/model/member/domain/usecase"
-
- "github.com/google/uuid"
- "golang.org/x/crypto/bcrypt"
-)
-
-type memberUseCase struct {
- repo domrepo.Repository
- tokens authusecase.TokenUseCase
-}
-
-func NewUseCase(repo domrepo.Repository, tokens authusecase.TokenUseCase) domusecase.UseCase {
- return &memberUseCase{repo: repo, tokens: tokens}
-}
-
-func (u *memberUseCase) Register(ctx context.Context, req domusecase.RegisterRequest) (*entity.Member, *domusecase.AuthToken, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- email := normalizeEmail(req.Email)
- if tenantID == "" || email == "" || req.Password == "" {
- return nil, nil, app.For(code.Member).InputMissingRequired("tenant_id, email, and password are required")
- }
- if len(req.Password) < 8 {
- return nil, nil, app.For(code.Member).InputInvalidFormat("password must be at least 8 characters")
- }
- hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
- if err != nil {
- return nil, nil, app.For(code.Member).SysInternal("hash password failed").WithCause(err)
- }
- member, err := u.repo.Create(ctx, &entity.Member{
- TenantID: tenantID,
- UID: uuid.NewString(),
- Email: email,
- DisplayName: strings.TrimSpace(req.DisplayName),
- Language: strings.TrimSpace(req.Language),
- Status: entity.StatusOpen,
- Origin: entity.OriginNative,
- PasswordHash: string(hash),
- Roles: []string{"user"},
- })
- if err != nil {
- return nil, nil, err
- }
- token, err := u.issue(ctx, member)
- return member, token, err
-}
-
-func (u *memberUseCase) Login(ctx context.Context, req domusecase.LoginRequest) (*entity.Member, *domusecase.AuthToken, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- email := normalizeEmail(req.Email)
- if tenantID == "" || email == "" || req.Password == "" {
- return nil, nil, app.For(code.Auth).InputMissingRequired("tenant_id, email, and password are required")
- }
- member, err := u.repo.FindByEmail(ctx, tenantID, email)
- if err != nil {
- return nil, nil, app.For(code.Auth).AuthUnauthorized("invalid email or password").WithCause(err)
- }
- if member.Status != entity.StatusOpen {
- return nil, nil, app.For(code.Auth).AuthForbidden("member is not active")
- }
- if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte(req.Password)); err != nil {
- return nil, nil, app.For(code.Auth).AuthUnauthorized("invalid email or password")
- }
- token, err := u.issue(ctx, member)
- return member, token, err
-}
-
-func (u *memberUseCase) ListMembers(ctx context.Context, req domusecase.ListMembersRequest) (*domusecase.ListMembersResult, error) {
- if strings.TrimSpace(req.TenantID) == "" {
- return nil, app.For(code.Member).InputMissingRequired("tenant_id is required")
- }
- items, total, page, pageSize, err := u.repo.List(ctx, strings.TrimSpace(req.TenantID), req.Page, req.PageSize)
- if err != nil {
- return nil, err
- }
- totalPages := int64(0)
- if pageSize > 0 {
- totalPages = (total + pageSize - 1) / pageSize
- }
- return &domusecase.ListMembersResult{List: items, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil
-}
-
-func (u *memberUseCase) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) {
- if tenantID == "" || uid == "" {
- return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- return u.repo.FindByUID(ctx, tenantID, uid)
-}
-
-func (u *memberUseCase) SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error {
- if tenantID == "" || uid == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- return u.repo.SetActiveThreadsAccountID(ctx, tenantID, uid, accountID)
-}
-
-func (u *memberUseCase) SetRoles(ctx context.Context, tenantID, uid string, roles []string) error {
- if tenantID == "" || uid == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- normalized := normalizeRoles(roles)
- if len(normalized) == 0 {
- return app.For(code.Member).InputInvalidFormat("roles must include user or admin")
- }
- for _, role := range normalized {
- if role != "user" && role != "admin" {
- return app.For(code.Member).InputInvalidFormat("roles only support user or admin")
- }
- }
- return u.repo.SetRoles(ctx, tenantID, uid, normalized)
-}
-
-func (u *memberUseCase) SetEmailVerificationCode(ctx context.Context, tenantID, uid, verifyCode string, expiresAt int64) error {
- if tenantID == "" || uid == "" || strings.TrimSpace(verifyCode) == "" || expiresAt <= 0 {
- return app.For(code.Member).InputMissingRequired("tenant_id, uid, code, and expires_at are required")
- }
- return u.repo.SetEmailVerificationCode(ctx, tenantID, uid, strings.TrimSpace(verifyCode), expiresAt)
-}
-
-func (u *memberUseCase) VerifyEmail(ctx context.Context, tenantID, uid, verifyCode string) (*entity.Member, error) {
- if tenantID == "" || uid == "" || strings.TrimSpace(verifyCode) == "" {
- return nil, app.For(code.Auth).InputMissingRequired("tenant_id, uid, and code are required")
- }
- member, err := u.repo.FindByUID(ctx, tenantID, uid)
- if err != nil {
- return nil, err
- }
- if member.Origin != "" && member.Origin != entity.OriginNative {
- if member.EmailVerified {
- return member, nil
- }
- return u.repo.UpdateProfile(ctx, tenantID, uid, domrepo.ProfileUpdate{
- EmailVerified: boolPtr(true),
- })
- }
- return u.repo.ConfirmEmailVerification(ctx, tenantID, uid, strings.TrimSpace(verifyCode))
-}
-
-func (u *memberUseCase) ResendEmailVerification(ctx context.Context, tenantID, uid string, send func(email, code string) error) error {
- if tenantID == "" || uid == "" {
- return app.For(code.Auth).InputMissingRequired("tenant_id and uid are required")
- }
- if send == nil {
- return app.For(code.Auth).SysNotImplemented("email sender is not configured")
- }
- member, err := u.repo.FindByUID(ctx, tenantID, uid)
- if err != nil {
- return err
- }
- if member.Origin != "" && member.Origin != entity.OriginNative {
- return nil
- }
- if member.EmailVerified {
- return nil
- }
- if member.EmailVerifyExpiresAt > 0 {
- sentAt := member.EmailVerifyExpiresAt - int64(15*time.Minute)
- if clock.NowUnixNano()-sentAt < int64(60*time.Second) {
- return app.For(code.Auth).ResInvalidState("verification email was sent recently; please wait before resending")
- }
- }
- verifyCode, err := generateVerifyCode()
- if err != nil {
- return err
- }
- expiresAt := clock.NowUnixNano() + int64(15*time.Minute)
- if err := u.repo.SetEmailVerificationCode(ctx, tenantID, uid, verifyCode, expiresAt); err != nil {
- return err
- }
- if err := send(member.Email, verifyCode); err != nil {
- return app.For(code.Auth).SvcThirdParty("驗證碼已產生但寄信失敗,請確認 SMTP 或 Mailpit 是否啟動").WithCause(err)
- }
- return nil
-}
-
-func boolPtr(value bool) *bool {
- return &value
-}
-
-func generateVerifyCode() (string, error) {
- n, err := rand.Int(rand.Reader, big.NewInt(1000000))
- if err != nil {
- return "", err
- }
- return fmt.Sprintf("%06d", n.Int64()), nil
-}
-
-func (u *memberUseCase) UpdatePassword(ctx context.Context, tenantID, uid, password string) error {
- if tenantID == "" || uid == "" || password == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id, uid, and password are required")
- }
- if len(password) < 8 {
- return app.For(code.Member).InputInvalidFormat("password must be at least 8 characters")
- }
- hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
- if err != nil {
- return app.For(code.Member).SysInternal("hash password failed").WithCause(err)
- }
- return u.repo.UpdatePassword(ctx, tenantID, uid, string(hash))
-}
-
-func (u *memberUseCase) UpdateProfile(ctx context.Context, req domusecase.UpdateProfileRequest) (*entity.Member, error) {
- if req.TenantID == "" || req.UID == "" {
- return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- var status *entity.Status
- if req.Status != nil {
- next := entity.Status(strings.TrimSpace(*req.Status))
- if next != entity.StatusOpen && next != entity.StatusSuspended && next != entity.StatusDeleted {
- return nil, app.For(code.Member).InputInvalidFormat("status only supports open, suspended, or deleted")
- }
- status = &next
- }
- return u.repo.UpdateProfile(ctx, req.TenantID, req.UID, domrepo.ProfileUpdate{
- DisplayName: req.DisplayName,
- Avatar: req.Avatar,
- Language: req.Language,
- Currency: req.Currency,
- Phone: req.Phone,
- EmailVerified: req.EmailVerified,
- Status: status,
- BusinessEmail: req.BusinessEmail,
- BusinessEmailVerified: req.BusinessEmailVerified,
- BusinessPhone: req.BusinessPhone,
- BusinessPhoneVerified: req.BusinessPhoneVerified,
- })
-}
-
-func (u *memberUseCase) issue(ctx context.Context, member *entity.Member) (*domusecase.AuthToken, error) {
- if u.tokens == nil {
- return nil, app.For(code.Auth).SysNotImplemented("token usecase is not configured")
- }
- pair, err := u.tokens.IssuePair(ctx, authusecase.IssuePairRequest{
- TenantID: member.TenantID,
- UID: member.UID,
- })
- if err != nil {
- return nil, err
- }
- return &domusecase.AuthToken{
- AccessToken: pair.AccessToken,
- RefreshToken: pair.RefreshToken,
- ExpiresIn: pair.ExpiresIn,
- UID: member.UID,
- TokenType: pair.TokenType,
- }, nil
-}
-
-func normalizeEmail(email string) string {
- return strings.ToLower(strings.TrimSpace(email))
-}
-
-func normalizeRoles(roles []string) []string {
- seen := make(map[string]bool, len(roles))
- out := make([]string, 0, len(roles))
- for _, role := range roles {
- role = strings.ToLower(strings.TrimSpace(role))
- if role == "" || seen[role] {
- continue
- }
- seen[role] = true
- out = append(out, role)
- }
- return out
-}
diff --git a/old/backend/internal/model/mention_inbox/domain/entity/mention.go b/old/backend/internal/model/mention_inbox/domain/entity/mention.go
deleted file mode 100644
index 71042fb..0000000
--- a/old/backend/internal/model/mention_inbox/domain/entity/mention.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package entity
-
-const CollectionName = "threads_mention_inbox"
-
-type Mention struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- MediaID string `bson:"media_id"`
- AuthorUsername string `bson:"author_username"`
- Text string `bson:"text"`
- Permalink string `bson:"permalink"`
- Shortcode string `bson:"shortcode,omitempty"`
- Timestamp string `bson:"timestamp,omitempty"`
- MediaType string `bson:"media_type,omitempty"`
- IsReply bool `bson:"is_reply"`
- IsQuotePost bool `bson:"is_quote_post"`
- HasReplies bool `bson:"has_replies"`
- RootPostID string `bson:"root_post_id,omitempty"`
- ParentID string `bson:"parent_id,omitempty"`
- ThreadPostText string `bson:"thread_post_text,omitempty"`
- ParentReplyText string `bson:"parent_reply_text,omitempty"`
- ReplyReady bool `bson:"reply_ready"`
- ReplyReadyMsg string `bson:"reply_ready_msg,omitempty"`
- SyncedAt int64 `bson:"synced_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-func DocID(accountID, mediaID string) string {
- return accountID + ":" + mediaID
-}
diff --git a/old/backend/internal/model/mention_inbox/domain/repository/repository.go b/old/backend/internal/model/mention_inbox/domain/repository/repository.go
deleted file mode 100644
index 902a05f..0000000
--- a/old/backend/internal/model/mention_inbox/domain/repository/repository.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/mention_inbox/domain/entity"
-)
-
-type ListResult struct {
- Items []entity.Mention
- Total int64
- ReadyTotal int64
- NewestSyncedAt int64
-}
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error)
- Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error)
- CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
- CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
- ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
-}
diff --git a/old/backend/internal/model/mention_inbox/domain/usecase/usecase.go b/old/backend/internal/model/mention_inbox/domain/usecase/usecase.go
deleted file mode 100644
index 8e6387c..0000000
--- a/old/backend/internal/model/mention_inbox/domain/usecase/usecase.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package usecase
-
-import "context"
-
-type MentionSummary struct {
- MediaID string
- AuthorUsername string
- Text string
- Permalink string
- Shortcode string
- Timestamp string
- MediaType string
- IsReply bool
- IsQuotePost bool
- HasReplies bool
- RootPostID string
- ParentID string
- ThreadPostText string
- ParentReplyText string
- ReplyReady bool
- ReplyReadyMsg string
- SyncedAt int64
-}
-
-type Pagination struct {
- Total int64
- ReadyTotal int64
- Page int64
- PageSize int64
- TotalPages int64
- NewestSyncedAt int64
-}
-
-type SyncRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Limit int
- MaxPages int
-}
-
-type SyncResult struct {
- Synced int
- Ready int
- Total int64
-}
-
-type ListRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Page int
- PageSize int
-}
-
-type ListResult struct {
- List []MentionSummary
- Pagination Pagination
-}
-
-type PublishReplyRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- MediaID string
- Text string
- ImageURLs []string
-}
-
-type PublishReplyResult struct {
- MediaID string
- Permalink string
-}
-
-type UseCase interface {
- SyncFromAPI(ctx context.Context, req SyncRequest) (*SyncResult, error)
- List(ctx context.Context, req ListRequest) (*ListResult, error)
- PublishReply(ctx context.Context, req PublishReplyRequest) (*PublishReplyResult, error)
-}
diff --git a/old/backend/internal/model/mention_inbox/repository/mongo.go b/old/backend/internal/model/mention_inbox/repository/mongo.go
deleted file mode 100644
index a639a80..0000000
--- a/old/backend/internal/model/mention_inbox/repository/mongo.go
+++ /dev/null
@@ -1,184 +0,0 @@
-package repository
-
-import (
- "context"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/mention_inbox/domain/entity"
- domrepo "haixun-backend/internal/model/mention_inbox/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "media_id", Value: 1},
- },
- Options: options.Index().SetUnique(true),
- },
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "synced_at", Value: -1},
- },
- },
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "timestamp", Value: -1},
- },
- },
- })
- return err
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, item *entity.Mention) (*entity.Mention, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if item == nil {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("mention is required")
- }
- filter := actorMediaFilter(item.TenantID, item.OwnerUID, item.AccountID, item.MediaID)
- opts := options.Replace().SetUpsert(true)
- if _, err := r.collection.ReplaceOne(ctx, filter, item, opts); err != nil {
- return nil, err
- }
- return item, nil
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Mention, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Mention
- err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("mention inbox item not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func normalizeMentionPage(page, pageSize int) (int, int) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 10
- }
- if pageSize > 50 {
- pageSize = 50
- }
- return page, pageSize
-}
-
-func accountFilter(tenantID, ownerUID, accountID string) bson.M {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "account_id": accountID,
- }
-}
-
-func (r *mongoRepository) CountByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- return r.collection.CountDocuments(ctx, accountFilter(tenantID, ownerUID, accountID))
-}
-
-func (r *mongoRepository) CountReplyReadyByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- filter := accountFilter(tenantID, ownerUID, accountID)
- filter["reply_ready"] = true
- return r.collection.CountDocuments(ctx, filter)
-}
-
-func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domrepo.ListResult, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- page, pageSize = normalizeMentionPage(page, pageSize)
- filter := accountFilter(tenantID, ownerUID, accountID)
- total, err := r.collection.CountDocuments(ctx, filter)
- if err != nil {
- return nil, err
- }
- sort := bson.D{{Key: "timestamp", Value: -1}, {Key: "synced_at", Value: -1}}
- opts := options.Find().
- SetSort(sort).
- SetSkip(int64((page - 1) * pageSize)).
- SetLimit(int64(pageSize))
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- out := make([]entity.Mention, 0, pageSize)
- for cur.Next(ctx) {
- var item entity.Mention
- if err := cur.Decode(&item); err != nil {
- return nil, err
- }
- out = append(out, item)
- }
- if err := cur.Err(); err != nil {
- return nil, err
- }
- var newest entity.Mention
- newestErr := r.collection.FindOne(ctx, filter, options.FindOne().SetSort(bson.D{{Key: "synced_at", Value: -1}})).Decode(&newest)
- newestSyncedAt := int64(0)
- if newestErr == nil {
- newestSyncedAt = newest.SyncedAt
- }
- readyTotal, err := r.CountReplyReadyByAccount(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- return &domrepo.ListResult{
- Items: out,
- Total: total,
- ReadyTotal: readyTotal,
- NewestSyncedAt: newestSyncedAt,
- }, nil
-}
-
-func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "account_id": accountID,
- "media_id": mediaID,
- }
-}
diff --git a/old/backend/internal/model/mention_inbox/usecase/usecase.go b/old/backend/internal/model/mention_inbox/usecase/usecase.go
deleted file mode 100644
index 61c8329..0000000
--- a/old/backend/internal/model/mention_inbox/usecase/usecase.go
+++ /dev/null
@@ -1,353 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/threadspost"
- "haixun-backend/internal/model/mention_inbox/domain/entity"
- domrepo "haixun-backend/internal/model/mention_inbox/domain/repository"
- domusecase "haixun-backend/internal/model/mention_inbox/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type mentionInboxUseCase struct {
- repo domrepo.Repository
- threadsAccounts threadsaccountdomain.UseCase
-}
-
-func NewUseCase(repo domrepo.Repository, threadsAccounts threadsaccountdomain.UseCase) domusecase.UseCase {
- return &mentionInboxUseCase{repo: repo, threadsAccounts: threadsAccounts}
-}
-
-func (u *mentionInboxUseCase) SyncFromAPI(ctx context.Context, req domusecase.SyncRequest) (*domusecase.SyncResult, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- ownerUID := strings.TrimSpace(req.OwnerUID)
- accountID := strings.TrimSpace(req.AccountID)
- if tenantID == "" || ownerUID == "" {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if accountID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
-
- account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- client := libthreads.NewClient(token)
- if !client.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
- }
-
- userID := strings.TrimSpace(account.ThreadsUserID)
- if userID == "" {
- if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
- userID = profile.ID.String()
- }
- }
- if userID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id")
- }
-
- perPage := req.Limit
- if perPage <= 0 {
- perPage = 25
- }
- if perPage > 100 {
- perPage = 100
- }
- maxPages := req.MaxPages
- if maxPages <= 0 {
- maxPages = 4
- }
- if maxPages > 20 {
- maxPages = 20
- }
-
- now := clock.NowUnixNano()
- synced := 0
- ready := 0
- after := ""
- for page := 0; page < maxPages; page++ {
- mentions, nextAfter, err := client.UserMentionsPage(ctx, userID, perPage, after)
- if err != nil {
- return nil, err
- }
- for _, hit := range mentions {
- item := u.buildMentionEntity(tenantID, ownerUID, accountID, hit, now)
- u.fillContext(ctx, client, hit, item)
- u.primeReplyTarget(ctx, client, hit, item)
- if _, err := u.repo.Upsert(ctx, item); err != nil {
- return nil, err
- }
- synced++
- if item.ReplyReady {
- ready++
- }
- }
- if nextAfter == "" || len(mentions) == 0 {
- break
- }
- after = nextAfter
- }
-
- total, err := u.repo.CountByAccount(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
-
- return &domusecase.SyncResult{
- Synced: synced,
- Ready: ready,
- Total: total,
- }, nil
-}
-
-func (u *mentionInboxUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- ownerUID := strings.TrimSpace(req.OwnerUID)
- accountID := strings.TrimSpace(req.AccountID)
- if tenantID == "" || ownerUID == "" {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if accountID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- if _, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID); err != nil {
- return nil, err
- }
- result, err := u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID, req.Page, req.PageSize)
- if err != nil {
- return nil, err
- }
- page, pageSize := normalizeListPage(req.Page, req.PageSize)
- return &domusecase.ListResult{
- List: toSummaries(result.Items),
- Pagination: domusecase.Pagination{
- Total: result.Total,
- ReadyTotal: result.ReadyTotal,
- Page: int64(page),
- PageSize: int64(pageSize),
- TotalPages: mentionTotalPages(result.Total, pageSize),
- NewestSyncedAt: result.NewestSyncedAt,
- },
- }, nil
-}
-
-func normalizeListPage(page, pageSize int) (int, int) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 10
- }
- if pageSize > 50 {
- pageSize = 50
- }
- return page, pageSize
-}
-
-func mentionTotalPages(total int64, pageSize int) int64 {
- if total <= 0 {
- return 0
- }
- return (total + int64(pageSize) - 1) / int64(pageSize)
-}
-
-func (u *mentionInboxUseCase) PublishReply(ctx context.Context, req domusecase.PublishReplyRequest) (*domusecase.PublishReplyResult, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- ownerUID := strings.TrimSpace(req.OwnerUID)
- accountID := strings.TrimSpace(req.AccountID)
- mediaID := strings.TrimSpace(req.MediaID)
- text := strings.TrimSpace(req.Text)
- imageURLs := req.ImageURLs
- if tenantID == "" || ownerUID == "" {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if accountID == "" || mediaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
- }
- if text == "" && len(imageURLs) == 0 {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("text or image_keys is required")
- }
- if err := threadspost.ValidateReplyContent(text, len(imageURLs) > 0); err != nil {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
-
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID)
- if err != nil {
- return nil, err
- }
- if !item.ReplyReady {
- msg := strings.TrimSpace(item.ReplyReadyMsg)
- if msg == "" {
- msg = "此提及尚未完成回覆註冊,請先重新同步提及清單"
- }
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(msg)
- }
-
- account, err := u.threadsAccounts.Get(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- token, err := u.threadsAccounts.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- userID := strings.TrimSpace(account.ThreadsUserID)
- if userID == "" {
- if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
- userID = profile.ID.String()
- }
- }
- if userID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("缺少 threads_user_id")
- }
-
- result, err := libthreads.PublishReply(ctx, libthreads.PublishReplyInput{
- ThreadsUserID: userID,
- AccessToken: token,
- ReplyToID: mediaID,
- Text: text,
- ImageURLs: imageURLs,
- })
- if err != nil {
- return nil, err
- }
- out := &domusecase.PublishReplyResult{}
- if result != nil {
- out.MediaID = strings.TrimSpace(result.MediaID)
- out.Permalink = strings.TrimSpace(result.Permalink)
- }
- return out, nil
-}
-
-func (u *mentionInboxUseCase) buildMentionEntity(
- tenantID, ownerUID, accountID string,
- hit libthreads.MentionItem,
- now int64,
-) *entity.Mention {
- mediaID := strings.TrimSpace(hit.ID)
- return &entity.Mention{
- ID: entity.DocID(accountID, mediaID),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- AccountID: accountID,
- MediaID: mediaID,
- AuthorUsername: strings.TrimSpace(hit.Username),
- Text: strings.TrimSpace(hit.Text),
- Permalink: strings.TrimSpace(hit.Permalink),
- Shortcode: shortcodeFromPermalink(hit.Permalink),
- Timestamp: strings.TrimSpace(hit.Timestamp),
- MediaType: strings.TrimSpace(hit.MediaType),
- IsReply: hit.IsReply,
- IsQuotePost: hit.IsQuotePost,
- HasReplies: hit.HasReplies,
- RootPostID: strings.TrimSpace(hit.RootPostID),
- ParentID: strings.TrimSpace(hit.ParentID),
- SyncedAt: now,
- UpdateAt: now,
- }
-}
-
-func (u *mentionInboxUseCase) fillContext(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) {
- mentionText := strings.TrimSpace(hit.Text)
- if !hit.IsReply && !hit.IsQuotePost {
- item.ThreadPostText = mentionText
- return
- }
-
- rootID := strings.TrimSpace(hit.RootPostID)
- if rootID == "" {
- rootID = strings.TrimSpace(hit.ID)
- }
- if rootID != "" {
- if detail, err := client.GetMediaDetail(ctx, rootID); err == nil && detail != nil {
- item.ThreadPostText = strings.TrimSpace(detail.Text)
- }
- }
- if item.ThreadPostText == "" {
- item.ThreadPostText = mentionText
- }
-
- parentID := strings.TrimSpace(hit.ParentID)
- mediaID := strings.TrimSpace(hit.ID)
- if hit.IsReply && parentID != "" && parentID != mediaID && parentID != rootID {
- if detail, err := client.GetMediaDetail(ctx, parentID); err == nil && detail != nil {
- item.ParentReplyText = strings.TrimSpace(detail.Text)
- }
- }
-}
-
-func (u *mentionInboxUseCase) primeReplyTarget(ctx context.Context, client *libthreads.Client, hit libthreads.MentionItem, item *entity.Mention) {
- permalink := strings.TrimSpace(hit.Permalink)
- mediaID := strings.TrimSpace(hit.ID)
- if permalink == "" {
- item.ReplyReady = false
- item.ReplyReadyMsg = "缺少 permalink,無法註冊回覆目標"
- return
- }
- if err := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
- ExternalID: mediaID,
- Permalink: permalink,
- HintText: strings.TrimSpace(hit.Text),
- }, mediaID); err != nil {
- item.ReplyReady = false
- item.ReplyReadyMsg = err.Error()
- return
- }
- item.ReplyReady = true
- item.ReplyReadyMsg = ""
-}
-
-func shortcodeFromPermalink(permalink string) string {
- permalink = strings.Split(strings.TrimSpace(permalink), "?")[0]
- permalink = strings.Split(permalink, "#")[0]
- parts := strings.Split(permalink, "/post/")
- if len(parts) < 2 {
- return ""
- }
- return strings.TrimSpace(parts[len(parts)-1])
-}
-
-func toSummary(item *entity.Mention) domusecase.MentionSummary {
- if item == nil {
- return domusecase.MentionSummary{}
- }
- return domusecase.MentionSummary{
- MediaID: item.MediaID,
- AuthorUsername: item.AuthorUsername,
- Text: item.Text,
- Permalink: item.Permalink,
- Shortcode: item.Shortcode,
- Timestamp: item.Timestamp,
- MediaType: item.MediaType,
- IsReply: item.IsReply,
- IsQuotePost: item.IsQuotePost,
- HasReplies: item.HasReplies,
- RootPostID: item.RootPostID,
- ParentID: item.ParentID,
- ThreadPostText: item.ThreadPostText,
- ParentReplyText: item.ParentReplyText,
- ReplyReady: item.ReplyReady,
- ReplyReadyMsg: item.ReplyReadyMsg,
- SyncedAt: item.SyncedAt,
- }
-}
-
-func toSummaries(items []entity.Mention) []domusecase.MentionSummary {
- out := make([]domusecase.MentionSummary, 0, len(items))
- for i := range items {
- out = append(out, toSummary(&items[i]))
- }
- return out
-}
diff --git a/old/backend/internal/model/outreach_draft/domain/entity/draft.go b/old/backend/internal/model/outreach_draft/domain/entity/draft.go
deleted file mode 100644
index dea900d..0000000
--- a/old/backend/internal/model/outreach_draft/domain/entity/draft.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package entity
-
-const CollectionName = "outreach_drafts"
-
-type DraftItem struct {
- Text string `bson:"text"`
- Angle string `bson:"angle"`
- Rationale string `bson:"rationale"`
-}
-
-type OutreachDraft struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id"`
- TopicID string `bson:"topic_id,omitempty"`
- LegacyPersonaID string `bson:"persona_id,omitempty"`
- ScanPostID string `bson:"scan_post_id"`
- Relevance float64 `bson:"relevance"`
- Reason string `bson:"reason"`
- Drafts []DraftItem `bson:"drafts"`
- CreateAt int64 `bson:"create_at"`
-}
diff --git a/old/backend/internal/model/outreach_draft/domain/repository/repository.go b/old/backend/internal/model/outreach_draft/domain/repository/repository.go
deleted file mode 100644
index 2023a3c..0000000
--- a/old/backend/internal/model/outreach_draft/domain/repository/repository.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/outreach_draft/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, draft *entity.OutreachDraft) error
- GetByID(ctx context.Context, tenantID, ownerUID, brandID, draftID string) (*entity.OutreachDraft, error)
- GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*entity.OutreachDraft, error)
- UpdateDrafts(ctx context.Context, tenantID, ownerUID, brandID, draftID string, drafts []entity.DraftItem) (*entity.OutreachDraft, error)
-}
diff --git a/old/backend/internal/model/outreach_draft/domain/usecase/usecase.go b/old/backend/internal/model/outreach_draft/domain/usecase/usecase.go
deleted file mode 100644
index 846eb85..0000000
--- a/old/backend/internal/model/outreach_draft/domain/usecase/usecase.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package usecase
-
-import (
- "context"
-)
-
-type DraftItem struct {
- Text string
- Angle string
- Rationale string
-}
-
-type DraftSummary struct {
- ID string
- BrandID string
- ScanPostID string
- Relevance float64
- Reason string
- Drafts []DraftItem
- CreateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- ScanPostID string
- Relevance float64
- Reason string
- Drafts []DraftItem
-}
-
-type UpdateDraftItemRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- DraftID string
- DraftIndex int
- Text string
-}
-
-type UseCase interface {
- Create(ctx context.Context, req CreateRequest) (*DraftSummary, error)
- GetLatestByScanPost(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanPostID string) (*DraftSummary, error)
- UpdateDraftItem(ctx context.Context, req UpdateDraftItemRequest) (*DraftSummary, error)
-}
diff --git a/old/backend/internal/model/outreach_draft/repository/mongo.go b/old/backend/internal/model/outreach_draft/repository/mongo.go
deleted file mode 100644
index badee67..0000000
--- a/old/backend/internal/model/outreach_draft/repository/mongo.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/model/outreach_draft/domain/entity"
- domrepo "haixun-backend/internal/model/outreach_draft/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "brand_id", Value: 1},
- {Key: "scan_post_id", Value: 1},
- {Key: "create_at", Value: -1},
- },
- },
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "persona_id", Value: 1},
- {Key: "scan_post_id", Value: 1},
- {Key: "create_at", Value: -1},
- },
- },
- })
- return err
-}
-
-func brandOwnerFilter(tenantID, ownerUID, brandID string) bson.M {
- filter := bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- }
- for k, v := range libmongo.BrandScopeFilter(brandID) {
- filter[k] = v
- }
- return filter
-}
-
-func (r *mongoRepository) Create(ctx context.Context, draft *entity.OutreachDraft) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if draft == nil {
- return app.For(code.Brand).InputMissingRequired("draft is required")
- }
- _, err := r.collection.InsertOne(ctx, draft)
- return err
-}
-
-func draftScopeFilter(tenantID, ownerUID, brandID, topicID string) bson.M {
- filter := brandOwnerFilter(tenantID, ownerUID, brandID)
- topicID = strings.TrimSpace(topicID)
- if topicID != "" {
- filter["topic_id"] = topicID
- }
- return filter
-}
-
-func (r *mongoRepository) GetByID(
- ctx context.Context,
- tenantID, ownerUID, brandID, draftID string,
-) (*entity.OutreachDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- draftID = strings.TrimSpace(draftID)
- if draftID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("draft_id is required")
- }
- filter := brandOwnerFilter(tenantID, ownerUID, brandID)
- filter["_id"] = draftID
- var out entity.OutreachDraft
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("outreach draft not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) UpdateDrafts(
- ctx context.Context,
- tenantID, ownerUID, brandID, draftID string,
- drafts []entity.DraftItem,
-) (*entity.OutreachDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- draftID = strings.TrimSpace(draftID)
- if draftID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("draft_id is required")
- }
- filter := brandOwnerFilter(tenantID, ownerUID, brandID)
- filter["_id"] = draftID
- update := bson.M{"$set": bson.M{"drafts": drafts}}
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.OutreachDraft
- err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("outreach draft not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) GetLatestByScanPost(
- ctx context.Context,
- tenantID, ownerUID, brandID, topicID, scanPostID string,
-) (*entity.OutreachDraft, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- filter := draftScopeFilter(tenantID, ownerUID, brandID, topicID)
- filter["scan_post_id"] = strings.TrimSpace(scanPostID)
- opts := options.FindOne().SetSort(bson.D{{Key: "create_at", Value: -1}})
- var out entity.OutreachDraft
- err := r.collection.FindOne(ctx, filter, opts).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/outreach_draft/usecase/errors.go b/old/backend/internal/model/outreach_draft/usecase/errors.go
deleted file mode 100644
index be64b44..0000000
--- a/old/backend/internal/model/outreach_draft/usecase/errors.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package usecase
-
-import (
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func errMissingActor() error {
- return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required")
-}
-
-func errMissingBrand() error {
- return app.For(code.Brand).InputMissingRequired("brand id is required")
-}
-
-func errMissingScanPost() error {
- return app.For(code.Brand).InputMissingRequired("scan_post_id is required")
-}
-
-func errMissingDraftID() error {
- return app.For(code.Brand).InputMissingRequired("draft_id is required")
-}
-
-func errMissingDraftText() error {
- return app.For(code.Brand).InputMissingRequired("draft text cannot be empty")
-}
-
-func errDraftNotFound() error {
- return app.For(code.Brand).ResNotFound("outreach draft not found")
-}
-
-func errInvalidDraftIndex() error {
- return app.For(code.Brand).InputInvalidFormat("draft_index out of range")
-}
diff --git a/old/backend/internal/model/outreach_draft/usecase/usecase.go b/old/backend/internal/model/outreach_draft/usecase/usecase.go
deleted file mode 100644
index bdf7b47..0000000
--- a/old/backend/internal/model/outreach_draft/usecase/usecase.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/model/outreach_draft/domain/entity"
- domrepo "haixun-backend/internal/model/outreach_draft/domain/repository"
- domusecase "haixun-backend/internal/model/outreach_draft/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type outreachDraftUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &outreachDraftUseCase{repo: repo}
-}
-
-func (u *outreachDraftUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.DraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil {
- return nil, err
- }
- scanPostID := strings.TrimSpace(req.ScanPostID)
- if scanPostID == "" {
- return nil, errMissingScanPost()
- }
- now := clock.NowUnixNano()
- drafts := make([]entity.DraftItem, 0, len(req.Drafts))
- for _, item := range req.Drafts {
- drafts = append(drafts, entity.DraftItem{
- Text: item.Text,
- Angle: item.Angle,
- Rationale: item.Rationale,
- })
- }
- record := &entity.OutreachDraft{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- BrandID: req.BrandID,
- TopicID: strings.TrimSpace(req.TopicID),
- ScanPostID: scanPostID,
- Relevance: req.Relevance,
- Reason: req.Reason,
- Drafts: drafts,
- CreateAt: now,
- }
- if err := u.repo.Create(ctx, record); err != nil {
- return nil, err
- }
- return toSummary(record), nil
-}
-
-func (u *outreachDraftUseCase) UpdateDraftItem(
- ctx context.Context,
- req domusecase.UpdateDraftItemRequest,
-) (*domusecase.DraftSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil {
- return nil, err
- }
- draftID := strings.TrimSpace(req.DraftID)
- if draftID == "" {
- return nil, errMissingDraftID()
- }
- text := strings.TrimSpace(req.Text)
- if text == "" {
- return nil, errMissingDraftText()
- }
- record, err := u.repo.GetByID(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID)
- if err != nil {
- return nil, err
- }
- if record == nil || len(record.Drafts) == 0 {
- return nil, errDraftNotFound()
- }
- if req.DraftIndex < 0 || req.DraftIndex >= len(record.Drafts) {
- return nil, errInvalidDraftIndex()
- }
- record.Drafts[req.DraftIndex].Text = text
- updated, err := u.repo.UpdateDrafts(ctx, req.TenantID, req.OwnerUID, req.BrandID, draftID, record.Drafts)
- if err != nil {
- return nil, err
- }
- return toSummary(updated), nil
-}
-
-func (u *outreachDraftUseCase) GetLatestByScanPost(
- ctx context.Context,
- tenantID, ownerUID, brandID, topicID, scanPostID string,
-) (*domusecase.DraftSummary, error) {
- if err := requireActor(tenantID, ownerUID, brandID); err != nil {
- return nil, err
- }
- scanPostID = strings.TrimSpace(scanPostID)
- if scanPostID == "" {
- return nil, errMissingScanPost()
- }
- record, err := u.repo.GetLatestByScanPost(ctx, tenantID, ownerUID, brandID, topicID, scanPostID)
- if err != nil {
- return nil, err
- }
- if record == nil {
- return nil, nil
- }
- return toSummary(record), nil
-}
-
-func requireActor(tenantID, ownerUID, brandID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return errMissingActor()
- }
- if strings.TrimSpace(brandID) == "" {
- return errMissingBrand()
- }
- return nil
-}
-
-func toSummary(record *entity.OutreachDraft) *domusecase.DraftSummary {
- if record == nil {
- return nil
- }
- drafts := make([]domusecase.DraftItem, 0, len(record.Drafts))
- for _, item := range record.Drafts {
- drafts = append(drafts, domusecase.DraftItem{
- Text: item.Text,
- Angle: item.Angle,
- Rationale: item.Rationale,
- })
- }
- return &domusecase.DraftSummary{
- ID: record.ID,
- BrandID: libmongo.ResolveBrandID(record.BrandID, record.LegacyPersonaID),
- ScanPostID: record.ScanPostID,
- Relevance: record.Relevance,
- Reason: record.Reason,
- Drafts: drafts,
- CreateAt: record.CreateAt,
- }
-}
diff --git a/old/backend/internal/model/own_post_formula/domain/entity/review.go b/old/backend/internal/model/own_post_formula/domain/entity/review.go
deleted file mode 100644
index b6376c5..0000000
--- a/old/backend/internal/model/own_post_formula/domain/entity/review.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package entity
-
-const CollectionName = "own_post_formula_reviews"
-
-type Review struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- MediaID string `bson:"media_id"`
- PersonaID string `bson:"persona_id,omitempty"`
- PostText string `bson:"post_text,omitempty"`
- Summary string `bson:"summary"`
- Wins []string `bson:"wins,omitempty"`
- Improvements []string `bson:"improvements,omitempty"`
- Formula string `bson:"formula"`
- PostTemplate string `bson:"post_template"`
- HookPattern string `bson:"hook_pattern"`
- Structure string `bson:"structure"`
- ReplicationTips []string `bson:"replication_tips,omitempty"`
- Avoid []string `bson:"avoid,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-func ReviewDocID(accountID, mediaID string) string {
- return accountID + ":" + mediaID
-}
diff --git a/old/backend/internal/model/own_post_formula/domain/repository/repository.go b/old/backend/internal/model/own_post_formula/domain/repository/repository.go
deleted file mode 100644
index 7b1601e..0000000
--- a/old/backend/internal/model/own_post_formula/domain/repository/repository.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/own_post_formula/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error)
- Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error)
- ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error)
-}
diff --git a/old/backend/internal/model/own_post_formula/repository/mongo.go b/old/backend/internal/model/own_post_formula/repository/mongo.go
deleted file mode 100644
index 4c60838..0000000
--- a/old/backend/internal/model/own_post_formula/repository/mongo.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/own_post_formula/domain/entity"
- domrepo "haixun-backend/internal/model/own_post_formula/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "media_id", Value: 1},
- },
- Options: options.Index().SetUnique(true),
- },
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "update_at", Value: -1},
- },
- },
- })
- return err
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, review *entity.Review) (*entity.Review, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if review == nil {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required")
- }
- filter := actorMediaFilter(review.TenantID, review.OwnerUID, review.AccountID, review.MediaID)
- opts := options.Replace().SetUpsert(true)
- if _, err := r.collection.ReplaceOne(ctx, filter, review, opts); err != nil {
- return nil, err
- }
- return review, nil
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Review
- err := r.collection.FindOne(ctx, actorMediaFilter(tenantID, ownerUID, accountID, mediaID)).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("own post formula review not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.collection.Find(
- ctx,
- bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "account_id": strings.TrimSpace(accountID),
- },
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.Review
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func actorMediaFilter(tenantID, ownerUID, accountID, mediaID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "account_id": strings.TrimSpace(accountID),
- "media_id": strings.TrimSpace(mediaID),
- }
-}
diff --git a/old/backend/internal/model/own_post_formula/usecase/usecase.go b/old/backend/internal/model/own_post_formula/usecase/usecase.go
deleted file mode 100644
index eb9863b..0000000
--- a/old/backend/internal/model/own_post_formula/usecase/usecase.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
- "time"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libownpost "haixun-backend/internal/library/ownpost"
- "haixun-backend/internal/model/own_post_formula/domain/entity"
- domrepo "haixun-backend/internal/model/own_post_formula/domain/repository"
-)
-
-const FormulaCacheTTL = 5 * time.Minute
-
-type UseCase interface {
- Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error)
- SaveGenerated(ctx context.Context, tenantID, ownerUID, accountID, mediaID, personaID, postText string, review *libownpost.PostFormulaReview) (*entity.Review, error)
- ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error)
- IsFresh(review *entity.Review, now int64) bool
-}
-
-func (u *useCase) Get(ctx context.Context, tenantID, ownerUID, accountID, mediaID string) (*entity.Review, error) {
- return u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID)
-}
-
-func (u *useCase) IsFresh(review *entity.Review, now int64) bool {
- if review == nil || review.UpdateAt <= 0 {
- return false
- }
- if now <= 0 {
- now = clock.NowUnixNano()
- }
- return now-review.UpdateAt < int64(FormulaCacheTTL)
-}
-
-type useCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) UseCase {
- return &useCase{repo: repo}
-}
-
-func (u *useCase) SaveGenerated(
- ctx context.Context,
- tenantID, ownerUID, accountID, mediaID, personaID, postText string,
- review *libownpost.PostFormulaReview,
-) (*entity.Review, error) {
- if review == nil {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("review is required")
- }
- accountID = strings.TrimSpace(accountID)
- mediaID = strings.TrimSpace(mediaID)
- if accountID == "" || mediaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
- }
- now := clock.NowUnixNano()
- doc := &entity.Review{
- ID: entity.ReviewDocID(accountID, mediaID),
- TenantID: strings.TrimSpace(tenantID),
- OwnerUID: strings.TrimSpace(ownerUID),
- AccountID: accountID,
- MediaID: mediaID,
- PersonaID: strings.TrimSpace(personaID),
- PostText: strings.TrimSpace(postText),
- Summary: review.Summary,
- Wins: review.Wins,
- Improvements: review.Improvements,
- Formula: review.Formula,
- PostTemplate: review.PostTemplate,
- HookPattern: review.HookPattern,
- Structure: review.Structure,
- ReplicationTips: review.ReplicationTips,
- Avoid: review.Avoid,
- CreateAt: now,
- UpdateAt: now,
- }
- if existing, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, mediaID); err == nil && existing != nil {
- doc.CreateAt = existing.CreateAt
- }
- return u.repo.Upsert(ctx, doc)
-}
-
-func (u *useCase) ListByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]entity.Review, error) {
- accountID = strings.TrimSpace(accountID)
- if accountID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- return u.repo.ListByAccount(ctx, tenantID, ownerUID, accountID)
-}
diff --git a/old/backend/internal/model/permission/domain/entity/permission.go b/old/backend/internal/model/permission/domain/entity/permission.go
deleted file mode 100644
index 55c2559..0000000
--- a/old/backend/internal/model/permission/domain/entity/permission.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package entity
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-const PermissionCollectionName = "permissions"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusClose Status = "close"
-)
-
-type Type string
-
-const (
- TypeBackendUser Type = "backend_user"
- TypeFrontendUser Type = "frontend_user"
-)
-
-type Permission struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- Parent string `bson:"parent,omitempty"`
- Name string `bson:"name"`
- HTTPMethods string `bson:"http_methods,omitempty"`
- HTTPPath string `bson:"http_path,omitempty"`
- Status Status `bson:"status"`
- Type Type `bson:"type"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-type RolePermission struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- TenantID string `bson:"tenant_id"`
- RoleKey string `bson:"role_key"`
- PermissionID string `bson:"permission_id"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-const RolePermissionCollectionName = "role_permissions"
diff --git a/old/backend/internal/model/permission/domain/repository/permission.go b/old/backend/internal/model/permission/domain/repository/permission.go
deleted file mode 100644
index 1d006ba..0000000
--- a/old/backend/internal/model/permission/domain/repository/permission.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/permission/domain/entity"
-)
-
-type PermissionFilter struct {
- Status entity.Status
- Type entity.Type
-}
-
-type PermissionRepository interface {
- EnsureIndexes(ctx context.Context) error
- UpsertByName(ctx context.Context, permission *entity.Permission) error
- List(ctx context.Context, filter PermissionFilter) ([]*entity.Permission, error)
- ListByIDs(ctx context.Context, ids []string) ([]*entity.Permission, error)
-}
-
-type RolePermissionRepository interface {
- EnsureIndexes(ctx context.Context) error
- ListByRoles(ctx context.Context, tenantID string, roleKeys []string) ([]*entity.RolePermission, error)
- SetForRole(ctx context.Context, tenantID, roleKey string, permissionIDs []string) error
-}
diff --git a/old/backend/internal/model/permission/domain/usecase/permission.go b/old/backend/internal/model/permission/domain/usecase/permission.go
deleted file mode 100644
index 141dc7e..0000000
--- a/old/backend/internal/model/permission/domain/usecase/permission.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/member/domain/entity"
- permissionentity "haixun-backend/internal/model/permission/domain/entity"
-)
-
-type PermissionNode struct {
- ID string
- Parent string
- Name string
- HTTPMethods string
- HTTPPath string
- Status string
- Type string
- Children []PermissionNode
-}
-
-type CatalogRequest struct {
- Status string
- Type string
- Tree bool
-}
-
-type MePermissions struct {
- TenantID string
- UID string
- Roles []string
- Permissions map[string]string
- Tree []PermissionNode
-}
-
-type UseCase interface {
- EnsureDefaultPermissions(ctx context.Context) error
- EnsureAdminRegisterPermission(ctx context.Context) error
- EnsureDefaultRolePermissions(ctx context.Context, tenantID string) error
- Catalog(ctx context.Context, req CatalogRequest) ([]PermissionNode, []PermissionNode, error)
- Me(ctx context.Context, member *entity.Member, includeTree bool) (*MePermissions, error)
- ReplaceRolePermissions(ctx context.Context, tenantID, roleKey string, permissionIDs []string) error
-}
-
-type RawPermission = permissionentity.Permission
diff --git a/old/backend/internal/model/permission/repository/mongo_permission.go b/old/backend/internal/model/permission/repository/mongo_permission.go
deleted file mode 100644
index 7f4d3fb..0000000
--- a/old/backend/internal/model/permission/repository/mongo_permission.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/permission/domain/entity"
- domrepo "haixun-backend/internal/model/permission/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoPermissionRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoPermissionRepository(db *mongo.Database) domrepo.PermissionRepository {
- if db == nil {
- return &mongoPermissionRepository{}
- }
- return &mongoPermissionRepository{collection: db.Collection(entity.PermissionCollectionName)}
-}
-
-func (r *mongoPermissionRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "name", Value: 1}}, Options: options.Index().SetUnique(true)},
- {Keys: bson.D{{Key: "parent", Value: 1}}},
- {Keys: bson.D{{Key: "status", Value: 1}}},
- {Keys: bson.D{{Key: "type", Value: 1}}},
- })
- return err
-}
-
-func (r *mongoPermissionRepository) UpsertByName(ctx context.Context, permission *entity.Permission) error {
- if r.collection == nil {
- return app.For(code.Permission).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- if permission.Status == "" {
- permission.Status = entity.StatusOpen
- }
- if permission.Type == "" {
- permission.Type = entity.TypeBackendUser
- }
- _, err := r.collection.UpdateOne(
- ctx,
- bson.M{"name": permission.Name},
- bson.M{
- "$set": bson.M{
- "parent": permission.Parent,
- "name": permission.Name,
- "http_methods": permission.HTTPMethods,
- "http_path": permission.HTTPPath,
- "status": permission.Status,
- "type": permission.Type,
- "update_at": now,
- },
- "$setOnInsert": bson.M{"create_at": now},
- },
- options.Update().SetUpsert(true),
- )
- return err
-}
-
-func (r *mongoPermissionRepository) List(ctx context.Context, filter domrepo.PermissionFilter) ([]*entity.Permission, error) {
- if r.collection == nil {
- return nil, app.For(code.Permission).DBUnavailable("Mongo is not configured")
- }
- query := bson.M{}
- if filter.Status != "" {
- query["status"] = filter.Status
- }
- if filter.Type != "" {
- query["type"] = filter.Type
- }
- cursor, err := r.collection.Find(ctx, query, options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var out []*entity.Permission
- if err := cursor.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoPermissionRepository) ListByIDs(ctx context.Context, ids []string) ([]*entity.Permission, error) {
- if r.collection == nil {
- return nil, app.For(code.Permission).DBUnavailable("Mongo is not configured")
- }
- if len(ids) == 0 {
- return nil, nil
- }
- cursor, err := r.collection.Find(ctx, bson.M{"_id": bson.M{"$in": objectIDs(ids)}})
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var out []*entity.Permission
- if err := cursor.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
diff --git a/old/backend/internal/model/permission/repository/mongo_role_permission.go b/old/backend/internal/model/permission/repository/mongo_role_permission.go
deleted file mode 100644
index 5becfdf..0000000
--- a/old/backend/internal/model/permission/repository/mongo_role_permission.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/permission/domain/entity"
- domrepo "haixun-backend/internal/model/permission/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/bson/primitive"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRolePermissionRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRolePermissionRepository(db *mongo.Database) domrepo.RolePermissionRepository {
- if db == nil {
- return &mongoRolePermissionRepository{}
- }
- return &mongoRolePermissionRepository{collection: db.Collection(entity.RolePermissionCollectionName)}
-}
-
-func (r *mongoRolePermissionRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "role_key", Value: 1}, {Key: "permission_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "role_key", Value: 1}}},
- })
- return err
-}
-
-func (r *mongoRolePermissionRepository) ListByRoles(ctx context.Context, tenantID string, roleKeys []string) ([]*entity.RolePermission, error) {
- if r.collection == nil {
- return nil, app.For(code.Permission).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" || len(roleKeys) == 0 {
- return nil, nil
- }
- cursor, err := r.collection.Find(ctx, bson.M{"tenant_id": tenantID, "role_key": bson.M{"$in": roleKeys}})
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var out []*entity.RolePermission
- if err := cursor.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoRolePermissionRepository) SetForRole(ctx context.Context, tenantID, roleKey string, permissionIDs []string) error {
- if r.collection == nil {
- return app.For(code.Permission).DBUnavailable("Mongo is not configured")
- }
- if tenantID == "" || roleKey == "" {
- return app.For(code.Permission).InputMissingRequired("tenant_id and role_key are required")
- }
- if _, err := r.collection.DeleteMany(ctx, bson.M{"tenant_id": tenantID, "role_key": roleKey}); err != nil {
- return err
- }
- if len(permissionIDs) == 0 {
- return nil
- }
- now := clock.NowUnixNano()
- docs := make([]any, 0, len(permissionIDs))
- for _, permissionID := range permissionIDs {
- docs = append(docs, &entity.RolePermission{
- ID: primitive.NewObjectID(),
- TenantID: tenantID,
- RoleKey: roleKey,
- PermissionID: permissionID,
- CreateAt: now,
- UpdateAt: now,
- })
- }
- _, err := r.collection.InsertMany(ctx, docs)
- if mongo.IsDuplicateKeyError(err) {
- return nil
- }
- return err
-}
diff --git a/old/backend/internal/model/permission/repository/object_id.go b/old/backend/internal/model/permission/repository/object_id.go
deleted file mode 100644
index c617161..0000000
--- a/old/backend/internal/model/permission/repository/object_id.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package repository
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-func objectIDs(ids []string) []primitive.ObjectID {
- out := make([]primitive.ObjectID, 0, len(ids))
- for _, id := range ids {
- objectID, err := primitive.ObjectIDFromHex(id)
- if err == nil {
- out = append(out, objectID)
- }
- }
- return out
-}
diff --git a/old/backend/internal/model/permission/usecase/usecase.go b/old/backend/internal/model/permission/usecase/usecase.go
deleted file mode 100644
index b82f6e9..0000000
--- a/old/backend/internal/model/permission/usecase/usecase.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package usecase
-
-import (
- "context"
- "sort"
- "strings"
-
- memberentity "haixun-backend/internal/model/member/domain/entity"
- "haixun-backend/internal/model/permission/domain/entity"
- domrepo "haixun-backend/internal/model/permission/domain/repository"
- domusecase "haixun-backend/internal/model/permission/domain/usecase"
-)
-
-type permissionUseCase struct {
- permissions domrepo.PermissionRepository
- rolePermissions domrepo.RolePermissionRepository
-}
-
-func NewUseCase(permissions domrepo.PermissionRepository, rolePermissions domrepo.RolePermissionRepository) domusecase.UseCase {
- return &permissionUseCase{permissions: permissions, rolePermissions: rolePermissions}
-}
-
-func (u *permissionUseCase) EnsureDefaultPermissions(ctx context.Context) error {
- for _, permission := range defaultPermissions() {
- if err := u.permissions.UpsertByName(ctx, permission); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (u *permissionUseCase) EnsureAdminRegisterPermission(ctx context.Context) error {
- for _, permission := range adminRegisterPermissions() {
- if err := u.permissions.UpsertByName(ctx, permission); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (u *permissionUseCase) EnsureDefaultRolePermissions(ctx context.Context, tenantID string) error {
- if tenantID == "" {
- return nil
- }
- perms, err := u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen})
- if err != nil {
- return err
- }
- allIDs := make([]string, 0, len(perms))
- byName := make(map[string]string, len(perms))
- for _, item := range perms {
- id := item.ID.Hex()
- allIDs = append(allIDs, id)
- byName[item.Name] = id
- }
- if err := u.rolePermissions.SetForRole(ctx, tenantID, "admin", allIDs); err != nil {
- return err
- }
- userIDs := make([]string, 0, len(defaultUserPermissionNames()))
- for _, name := range defaultUserPermissionNames() {
- if id, ok := byName[name]; ok {
- userIDs = append(userIDs, id)
- }
- }
- return u.rolePermissions.SetForRole(ctx, tenantID, "user", userIDs)
-}
-
-func (u *permissionUseCase) Catalog(ctx context.Context, req domusecase.CatalogRequest) ([]domusecase.PermissionNode, []domusecase.PermissionNode, error) {
- filter := domrepo.PermissionFilter{}
- if req.Status != "" {
- filter.Status = entity.Status(req.Status)
- }
- if req.Type != "" {
- filter.Type = entity.Type(req.Type)
- }
- items, err := u.permissions.List(ctx, filter)
- if err != nil {
- return nil, nil, err
- }
- list := permissionNodes(items)
- if req.Tree {
- return buildTree(list), nil, nil
- }
- return nil, list, nil
-}
-
-func (u *permissionUseCase) Me(ctx context.Context, member *memberentity.Member, includeTree bool) (*domusecase.MePermissions, error) {
- if member == nil {
- return nil, nil
- }
- roles := member.Roles
- if len(roles) == 0 {
- roles = []string{"user"}
- }
- rolePermissions, err := u.rolePermissions.ListByRoles(ctx, member.TenantID, roles)
- if err != nil {
- return nil, err
- }
- ids := make([]string, 0, len(rolePermissions))
- for _, item := range rolePermissions {
- ids = append(ids, item.PermissionID)
- }
- perms, err := u.permissions.ListByIDs(ctx, ids)
- if err != nil {
- return nil, err
- }
- openPerms, err := u.permissions.List(ctx, domrepo.PermissionFilter{Status: entity.StatusOpen})
- if err != nil {
- return nil, err
- }
- if hasRole(roles, "admin") {
- perms = mergePermissions(perms, openPerms)
- } else {
- perms = mergePermissions(perms, filterPermissionsByName(openPerms, defaultUserPermissionNames()))
- }
- nodes := permissionNodes(perms)
- result := &domusecase.MePermissions{
- TenantID: member.TenantID,
- UID: member.UID,
- Roles: roles,
- Permissions: map[string]string{},
- }
- for _, node := range nodes {
- if node.HTTPPath == "" || node.HTTPMethods == "" {
- continue
- }
- if existing, ok := result.Permissions[node.HTTPPath]; ok {
- result.Permissions[node.HTTPPath] = mergeHTTPMethods(existing, node.HTTPMethods)
- continue
- }
- result.Permissions[node.HTTPPath] = node.HTTPMethods
- }
- if includeTree {
- result.Tree = buildTree(nodes)
- }
- return result, nil
-}
-
-func mergeHTTPMethods(existing, next string) string {
- seen := map[string]struct{}{}
- out := make([]string, 0, 8)
- for _, block := range []string{existing, next} {
- for _, item := range strings.Split(block, "|") {
- method := strings.ToUpper(strings.TrimSpace(item))
- if method == "" {
- continue
- }
- if _, ok := seen[method]; ok {
- continue
- }
- seen[method] = struct{}{}
- out = append(out, method)
- }
- }
- return strings.Join(out, "|")
-}
-
-func hasRole(roles []string, role string) bool {
- for _, item := range roles {
- if item == role {
- return true
- }
- }
- return false
-}
-
-func filterPermissionsByName(items []*entity.Permission, names []string) []*entity.Permission {
- allowed := map[string]struct{}{}
- for _, name := range names {
- allowed[name] = struct{}{}
- }
- out := make([]*entity.Permission, 0, len(items))
- for _, item := range items {
- if _, ok := allowed[item.Name]; ok {
- out = append(out, item)
- }
- }
- return out
-}
-
-func mergePermissions(base []*entity.Permission, extras []*entity.Permission) []*entity.Permission {
- seen := map[string]struct{}{}
- out := make([]*entity.Permission, 0, len(base)+len(extras))
- for _, group := range [][]*entity.Permission{base, extras} {
- for _, item := range group {
- if item == nil {
- continue
- }
- key := item.Name
- if key == "" {
- key = item.ID.Hex()
- }
- if _, ok := seen[key]; ok {
- continue
- }
- seen[key] = struct{}{}
- out = append(out, item)
- }
- }
- return out
-}
-
-func defaultUserPermissionNames() []string {
- return []string{
- "auth.logout",
- "auth.verify_email",
- "auth.resend_verification_email",
- "member.me.read",
- "member.me.update",
- "member.avatar.update",
- "member.capabilities.read",
- "member.placement_settings.read",
- "member.placement_settings.update",
- "permission.me.read",
- "setting.manage",
- "ai.use",
- "brand.manage",
- "persona.manage",
- "placement_topic.manage",
- "threads_account.manage",
- "job.manage",
- "job.schedule.manage",
- }
-}
-
-func (u *permissionUseCase) ReplaceRolePermissions(ctx context.Context, tenantID, roleKey string, permissionIDs []string) error {
- return u.rolePermissions.SetForRole(ctx, tenantID, roleKey, permissionIDs)
-}
-
-func permissionNodes(items []*entity.Permission) []domusecase.PermissionNode {
- out := make([]domusecase.PermissionNode, 0, len(items))
- for _, item := range items {
- out = append(out, domusecase.PermissionNode{
- ID: item.ID.Hex(),
- Parent: item.Parent,
- Name: item.Name,
- HTTPMethods: item.HTTPMethods,
- HTTPPath: item.HTTPPath,
- Status: string(item.Status),
- Type: string(item.Type),
- })
- }
- sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
- return out
-}
-
-func buildTree(items []domusecase.PermissionNode) []domusecase.PermissionNode {
- byParent := map[string][]domusecase.PermissionNode{}
- index := map[string]*domusecase.PermissionNode{}
- nodes := make([]domusecase.PermissionNode, len(items))
- copy(nodes, items)
- for i := range nodes {
- index[nodes[i].ID] = &nodes[i]
- byParent[nodes[i].Parent] = append(byParent[nodes[i].Parent], nodes[i])
- }
- for parent, children := range byParent {
- sort.SliceStable(children, func(i, j int) bool { return children[i].Name < children[j].Name })
- byParent[parent] = children
- }
- for parent, children := range byParent {
- if node, ok := index[parent]; ok {
- node.Children = children
- }
- }
- return byParent[""]
-}
-
-func defaultPermissions() []*entity.Permission {
- allMethods := "GET|POST|PUT|PATCH|DELETE"
- return []*entity.Permission{
- {Name: "auth.logout", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/logout", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "auth.verify_email", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/verify-email", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "auth.resend_verification_email", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/resend-verification-email", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
-
- {Name: "member.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "member.me.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "member.avatar.update", HTTPMethods: "POST", HTTPPath: "/api/v1/members/me/avatar", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "member.capabilities.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/capabilities", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "member.placement_settings.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "member.placement_settings.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
- {Name: "member.manage", HTTPMethods: "GET|PATCH", HTTPPath: "/api/v1/members/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
-
- {Name: "permission.catalog.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/catalog", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "permission.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
-
- {Name: "setting.manage", HTTPMethods: "GET|PUT|DELETE", HTTPPath: "/api/v1/settings/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "ai.use", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/ai/*", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
-
- {Name: "brand.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/brands/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "persona.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/personas/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "placement_topic.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/placement/topics/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "threads_account.manage", HTTPMethods: allMethods, HTTPPath: "/api/v1/threads-accounts/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
-
- {Name: "job.manage", HTTPMethods: "GET|POST", HTTPPath: "/api/v1/jobs/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "job.schedule.manage", HTTPMethods: "GET|POST|PUT|DELETE", HTTPPath: "/api/v1/job/schedules/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- {Name: "job.template.manage", HTTPMethods: "GET|PUT", HTTPPath: "/api/v1/job/templates/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- }
-}
-
-func adminRegisterPermissions() []*entity.Permission {
- return []*entity.Permission{
- {Name: "auth.register", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/register", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
- }
-}
diff --git a/old/backend/internal/model/permission/usecase/usecase_test.go b/old/backend/internal/model/permission/usecase/usecase_test.go
deleted file mode 100644
index 67c60cb..0000000
--- a/old/backend/internal/model/permission/usecase/usecase_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package usecase
-
-import "testing"
-
-func TestMergeHTTPMethods(t *testing.T) {
- got := mergeHTTPMethods("GET", "PATCH")
- if got != "GET|PATCH" {
- t.Fatalf("got %q", got)
- }
- got = mergeHTTPMethods("GET|POST", "POST|PATCH")
- if got != "GET|POST|PATCH" {
- t.Fatalf("got %q", got)
- }
-}
diff --git a/old/backend/internal/model/persona/domain/entity/copy_research_map.go b/old/backend/internal/model/persona/domain/entity/copy_research_map.go
deleted file mode 100644
index 5a4ecc9..0000000
--- a/old/backend/internal/model/persona/domain/entity/copy_research_map.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package entity
-
-type CopyResearchMap struct {
- AudienceSummary string `bson:"audience_summary,omitempty" json:"audience_summary,omitempty"`
- ContentGoal string `bson:"content_goal,omitempty" json:"content_goal,omitempty"`
- Questions []string `bson:"questions,omitempty" json:"questions,omitempty"`
- Pillars []string `bson:"pillars,omitempty" json:"pillars,omitempty"`
- Exclusions []string `bson:"exclusions,omitempty" json:"exclusions,omitempty"`
- SuggestedTags []string `bson:"suggested_tags,omitempty" json:"suggested_tags,omitempty"`
- BenchmarkNotes string `bson:"benchmark_notes,omitempty" json:"benchmark_notes,omitempty"`
-}
-
-func (m CopyResearchMap) IsEmpty() bool {
- return m.AudienceSummary == "" &&
- m.ContentGoal == "" &&
- len(m.Questions) == 0 &&
- len(m.SuggestedTags) == 0
-}
diff --git a/old/backend/internal/model/persona/domain/entity/persona.go b/old/backend/internal/model/persona/domain/entity/persona.go
deleted file mode 100644
index e9cc737..0000000
--- a/old/backend/internal/model/persona/domain/entity/persona.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package entity
-
-const CollectionName = "personas"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusDeleted Status = "deleted"
-)
-
-type Persona struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- DisplayName string `bson:"display_name,omitempty"`
- Persona string `bson:"persona,omitempty"`
- Brief string `bson:"brief,omitempty"`
- ProductBrief string `bson:"product_brief,omitempty"`
- TargetAudience string `bson:"target_audience,omitempty"`
- Goals string `bson:"goals,omitempty"`
- StyleProfile string `bson:"style_profile,omitempty"`
- StyleBenchmark string `bson:"style_benchmark,omitempty"`
- SeedQuery string `bson:"seed_query,omitempty"`
- CopyResearchMap CopyResearchMap `bson:"copy_research_map,omitempty"`
- Status Status `bson:"status"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/persona/domain/repository/repository.go b/old/backend/internal/model/persona/domain/repository/repository.go
deleted file mode 100644
index cd1f441..0000000
--- a/old/backend/internal/model/persona/domain/repository/repository.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/persona/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, persona *entity.Persona) (*entity.Persona, error)
- FindByID(ctx context.Context, tenantID, ownerUID, personaID string) (*entity.Persona, error)
- ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Persona, error)
- Update(ctx context.Context, tenantID, ownerUID, personaID string, patch map[string]interface{}) (*entity.Persona, error)
- SoftDelete(ctx context.Context, tenantID, ownerUID, personaID string) error
-}
diff --git a/old/backend/internal/model/persona/domain/usecase/usecase.go b/old/backend/internal/model/persona/domain/usecase/usecase.go
deleted file mode 100644
index 3c41fb1..0000000
--- a/old/backend/internal/model/persona/domain/usecase/usecase.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/persona/domain/entity"
-)
-
-type CopyResearchMapSummary struct {
- AudienceSummary string
- ContentGoal string
- Questions []string
- Pillars []string
- Exclusions []string
- SuggestedTags []string
- BenchmarkNotes string
-}
-
-type PersonaSummary struct {
- ID string
- DisplayName string
- Persona string
- Brief string
- ProductBrief string
- TargetAudience string
- Goals string
- StyleProfile string
- StyleBenchmark string
- SeedQuery string
- CopyResearchMap CopyResearchMapSummary
- CreateAt int64
- UpdateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- DisplayName string
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- Patch PersonaPatch
-}
-
-type PersonaPatch struct {
- DisplayName *string
- Persona *string
- Brief *string
- ProductBrief *string
- TargetAudience *string
- Goals *string
- StyleProfile *string
- StyleBenchmark *string
- SeedQuery *string
- CopyResearchMap *entity.CopyResearchMap
-}
-
-type ListResult struct {
- List []PersonaSummary
-}
-
-type UseCase interface {
- List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error)
- Create(ctx context.Context, req CreateRequest) (*PersonaSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, personaID string) (*PersonaSummary, error)
- Update(ctx context.Context, req UpdateRequest) (*PersonaSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID string) error
-}
diff --git a/old/backend/internal/model/persona/repository/mongo.go b/old/backend/internal/model/persona/repository/mongo.go
deleted file mode 100644
index f16df19..0000000
--- a/old/backend/internal/model/persona/repository/mongo.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/persona/domain/entity"
- domrepo "haixun-backend/internal/model/persona/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, persona *entity.Persona) (*entity.Persona, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- persona.CreateAt = now
- persona.UpdateAt = now
- if persona.Status == "" {
- persona.Status = entity.StatusOpen
- }
- _, err := r.collection.InsertOne(ctx, persona)
- if err != nil {
- return nil, err
- }
- return persona, nil
-}
-
-func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, personaID string) (*entity.Persona, error) {
- return r.findOne(ctx, bson.M{
- "_id": strings.TrimSpace(personaID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "status": entity.StatusOpen,
- })
-}
-
-func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Persona, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Persona
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, personaID string, patch map[string]interface{}) (*entity.Persona, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return r.FindByID(ctx, tenantID, ownerUID, personaID)
- }
- patch["update_at"] = clock.NowUnixNano()
- var out entity.Persona
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": personaID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": patch},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("persona not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, personaID string) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"_id": personaID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Persona).ResNotFound("persona not found")
- }
- return nil
-}
-
-func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Persona, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- var out entity.Persona
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("persona not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/persona/usecase/usecase.go b/old/backend/internal/model/persona/usecase/usecase.go
deleted file mode 100644
index 98377ac..0000000
--- a/old/backend/internal/model/persona/usecase/usecase.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/persona/domain/entity"
- domrepo "haixun-backend/internal/model/persona/domain/repository"
- domusecase "haixun-backend/internal/model/persona/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type personaUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &personaUseCase{repo: repo}
-}
-
-func (u *personaUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- list := make([]domusecase.PersonaSummary, 0, len(items))
- for _, item := range items {
- list = append(list, toSummary(item))
- }
- return &domusecase.ListResult{List: list}, nil
-}
-
-func (u *personaUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.PersonaSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- displayName := strings.TrimSpace(req.DisplayName)
- if displayName == "" {
- existing, err := u.repo.ListByOwner(ctx, req.TenantID, req.OwnerUID)
- if err != nil {
- return nil, err
- }
- displayName = "人設 " + itoa(len(existing)+1)
- }
- item, err := u.repo.Create(ctx, &entity.Persona{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- DisplayName: displayName,
- Status: entity.StatusOpen,
- })
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *personaUseCase) Get(ctx context.Context, tenantID, ownerUID, personaID string) (*domusecase.PersonaSummary, error) {
- item, err := u.assertOwned(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *personaUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID string) error {
- if _, err := u.assertOwned(ctx, tenantID, ownerUID, personaID); err != nil {
- return err
- }
- return u.repo.SoftDelete(ctx, tenantID, ownerUID, personaID)
-}
-
-func (u *personaUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.PersonaSummary, error) {
- if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- patch := patchToMap(req.Patch)
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.PersonaID, patch)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item)
- return &summary, nil
-}
-
-func (u *personaUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, personaID string) (*entity.Persona, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if strings.TrimSpace(personaID) == "" {
- return nil, app.For(code.Persona).InputMissingRequired("persona id is required")
- }
- return u.repo.FindByID(ctx, tenantID, ownerUID, personaID)
-}
-
-func requireActor(tenantID, ownerUID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Persona).InputMissingRequired("tenant_id and uid are required")
- }
- return nil
-}
-
-func toSummary(item *entity.Persona) domusecase.PersonaSummary {
- if item == nil {
- return domusecase.PersonaSummary{}
- }
- return domusecase.PersonaSummary{
- ID: item.ID,
- DisplayName: item.DisplayName,
- Persona: item.Persona,
- Brief: item.Brief,
- ProductBrief: item.ProductBrief,
- TargetAudience: item.TargetAudience,
- Goals: item.Goals,
- StyleProfile: item.StyleProfile,
- StyleBenchmark: item.StyleBenchmark,
- SeedQuery: item.SeedQuery,
- CopyResearchMap: toCopyMapSummary(item.CopyResearchMap),
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func toCopyMapSummary(m entity.CopyResearchMap) domusecase.CopyResearchMapSummary {
- return domusecase.CopyResearchMapSummary{
- AudienceSummary: m.AudienceSummary,
- ContentGoal: m.ContentGoal,
- Questions: append([]string(nil), m.Questions...),
- Pillars: append([]string(nil), m.Pillars...),
- Exclusions: append([]string(nil), m.Exclusions...),
- SuggestedTags: append([]string(nil), m.SuggestedTags...),
- BenchmarkNotes: m.BenchmarkNotes,
- }
-}
-
-func patchToMap(patch domusecase.PersonaPatch) map[string]interface{} {
- out := map[string]interface{}{}
- if patch.DisplayName != nil {
- out["display_name"] = strings.TrimSpace(*patch.DisplayName)
- }
- if patch.Persona != nil {
- out["persona"] = strings.TrimSpace(*patch.Persona)
- }
- if patch.Brief != nil {
- out["brief"] = strings.TrimSpace(*patch.Brief)
- }
- if patch.ProductBrief != nil {
- out["product_brief"] = strings.TrimSpace(*patch.ProductBrief)
- }
- if patch.TargetAudience != nil {
- out["target_audience"] = strings.TrimSpace(*patch.TargetAudience)
- }
- if patch.Goals != nil {
- out["goals"] = strings.TrimSpace(*patch.Goals)
- }
- if patch.StyleProfile != nil {
- out["style_profile"] = strings.TrimSpace(*patch.StyleProfile)
- }
- if patch.StyleBenchmark != nil {
- out["style_benchmark"] = strings.TrimSpace(*patch.StyleBenchmark)
- }
- if patch.SeedQuery != nil {
- out["seed_query"] = strings.TrimSpace(*patch.SeedQuery)
- }
- if patch.CopyResearchMap != nil {
- out["copy_research_map"] = *patch.CopyResearchMap
- }
- return out
-}
-
-func itoa(n int) string {
- if n <= 0 {
- return "1"
- }
- buf := make([]byte, 0, 12)
- for n > 0 {
- buf = append(buf, byte('0'+n%10))
- n /= 10
- }
- for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
- buf[i], buf[j] = buf[j], buf[i]
- }
- return string(buf)
-}
diff --git a/old/backend/internal/model/placement/usecase/settings.go b/old/backend/internal/model/placement/usecase/settings.go
deleted file mode 100644
index fee05de..0000000
--- a/old/backend/internal/model/placement/usecase/settings.go
+++ /dev/null
@@ -1,312 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/crypto"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/websearch"
- settingdomain "haixun-backend/internal/model/setting/domain/usecase"
-)
-
-const (
- settingScopeUser = "user"
- keyResearch = "placement.research"
-)
-
-type Settings struct {
- WebSearchProvider string
- BraveAPIKey string
- BraveAPIKeyConfigured bool
- ExaAPIKey string
- ExaAPIKeyConfigured bool
- BraveCountry string
- BraveSearchLang string
- ExaUserLocation string
- ExpandStrategy string
- DevModeEnabled bool
-}
-
-type SettingsPatch struct {
- WebSearchProvider *string
- BraveAPIKey *string
- ExaAPIKey *string
- BraveCountry *string
- BraveSearchLang *string
- ExaUserLocation *string
- ExpandStrategy *string
- DevModeEnabled *bool
-}
-
-type UseCase interface {
- Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error)
- Update(ctx context.Context, tenantID, ownerUID string, patch SettingsPatch) (*Settings, error)
- ResearchSettings(ctx context.Context, tenantID, ownerUID string) (placement.ResearchSettings, error)
-}
-
-type placementUseCase struct {
- settings settingdomain.UseCase
- cipher *crypto.Cipher
-}
-
-func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase {
- return &placementUseCase{settings: settings, cipher: cipher}
-}
-
-func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- stored, err := u.load(ctx, ownerUID)
- if err != nil {
- return nil, err
- }
- return toPublic(stored), nil
-}
-
-func (u *placementUseCase) Update(ctx context.Context, tenantID, ownerUID string, patch SettingsPatch) (*Settings, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- current, err := u.load(ctx, ownerUID)
- if err != nil {
- return nil, err
- }
- next := applyPatch(current, patch)
- if err := u.save(ctx, ownerUID, next); err != nil {
- return nil, err
- }
- return toPublic(next), nil
-}
-
-func (u *placementUseCase) ResearchSettings(ctx context.Context, tenantID, ownerUID string) (placement.ResearchSettings, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return placement.ResearchSettings{}, err
- }
- stored, err := u.load(ctx, ownerUID)
- if err != nil {
- return placement.ResearchSettings{}, err
- }
- return placement.ResearchSettings{
- WebSearchProvider: stored.WebSearchProvider,
- BraveAPIKey: stored.BraveAPIKey,
- ExaAPIKey: stored.ExaAPIKey,
- BraveCountry: stored.BraveCountry,
- BraveSearchLang: stored.BraveSearchLang,
- ExaUserLocation: stored.ExaUserLocation,
- ExpandStrategy: stored.ExpandStrategy,
- }, nil
-}
-
-func (u *placementUseCase) load(ctx context.Context, ownerUID string) (storedSettings, error) {
- defaults := defaultSettings()
- setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyResearch)
- if err != nil {
- if isSettingNotFound(err) {
- return defaults, nil
- }
- return defaults, err
- }
- merged := mergeSettings(defaults, setting.Value)
- if u.cipher != nil {
- if merged.BraveAPIKey, err = u.decryptKey(merged.BraveAPIKey); err != nil {
- return defaults, err
- }
- if merged.ExaAPIKey, err = u.decryptKey(merged.ExaAPIKey); err != nil {
- return defaults, err
- }
- }
- return merged, nil
-}
-
-func (u *placementUseCase) decryptKey(value string) (string, error) {
- if value == "" {
- return "", nil
- }
- plain, err := u.cipher.Decrypt(value)
- if err != nil {
- return "", err
- }
- return strings.TrimSpace(plain), nil
-}
-
-func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error {
- payload := value.toMap()
- if u.cipher != nil {
- for _, key := range []string{"brave_api_key", "exa_api_key"} {
- s, ok := payload[key].(string)
- if !ok || s == "" {
- continue
- }
- enc, err := u.cipher.Encrypt(s)
- if err != nil {
- return err
- }
- payload[key] = enc
- }
- }
- _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
- Scope: settingScopeUser,
- ScopeID: ownerUID,
- Key: keyResearch,
- Value: payload,
- })
- return err
-}
-
-type storedSettings struct {
- WebSearchProvider string
- BraveAPIKey string
- ExaAPIKey string
- BraveCountry string
- BraveSearchLang string
- ExaUserLocation string
- ExpandStrategy string
- DevModeEnabled bool
-}
-
-func defaultSettings() storedSettings {
- return storedSettings{
- WebSearchProvider: string(websearch.ProviderBrave),
- BraveCountry: "tw",
- BraveSearchLang: "zh-hant",
- ExaUserLocation: "TW",
- ExpandStrategy: string(libkg.ExpandStrategyHybrid),
- }
-}
-
-func mergeSettings(defaults storedSettings, raw map[string]interface{}) storedSettings {
- if raw == nil {
- return defaults
- }
- if v, ok := raw["web_search_provider"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.WebSearchProvider = string(websearch.ParseProvider(v))
- }
- if v, ok := raw["brave_api_key"].(string); ok {
- defaults.BraveAPIKey = strings.TrimSpace(v)
- }
- if v, ok := raw["exa_api_key"].(string); ok {
- defaults.ExaAPIKey = strings.TrimSpace(v)
- }
- if v, ok := raw["brave_country"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.BraveCountry = strings.TrimSpace(v)
- }
- if v, ok := raw["brave_search_lang"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.BraveSearchLang = strings.TrimSpace(v)
- }
- if v, ok := raw["exa_user_location"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.ExaUserLocation = strings.TrimSpace(v)
- }
- if v, ok := raw["expand_strategy"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.ExpandStrategy = string(libkg.ParseExpandStrategy(v))
- }
- if v, ok := raw["dev_mode_enabled"].(bool); ok {
- defaults.DevModeEnabled = v
- }
- return defaults
-}
-
-func applyPatch(current storedSettings, patch SettingsPatch) storedSettings {
- if patch.WebSearchProvider != nil && strings.TrimSpace(*patch.WebSearchProvider) != "" {
- current.WebSearchProvider = string(websearch.ParseProvider(*patch.WebSearchProvider))
- }
- if patch.BraveAPIKey != nil {
- value := strings.TrimSpace(*patch.BraveAPIKey)
- if value != "" && !isMaskedAPIKey(value) {
- current.BraveAPIKey = value
- }
- }
- if patch.ExaAPIKey != nil {
- value := strings.TrimSpace(*patch.ExaAPIKey)
- if value != "" && !isMaskedAPIKey(value) {
- current.ExaAPIKey = value
- }
- }
- if patch.BraveCountry != nil && strings.TrimSpace(*patch.BraveCountry) != "" {
- current.BraveCountry = strings.TrimSpace(*patch.BraveCountry)
- }
- if patch.BraveSearchLang != nil && strings.TrimSpace(*patch.BraveSearchLang) != "" {
- current.BraveSearchLang = strings.TrimSpace(*patch.BraveSearchLang)
- }
- if patch.ExaUserLocation != nil && strings.TrimSpace(*patch.ExaUserLocation) != "" {
- current.ExaUserLocation = strings.TrimSpace(*patch.ExaUserLocation)
- }
- if patch.ExpandStrategy != nil && strings.TrimSpace(*patch.ExpandStrategy) != "" {
- current.ExpandStrategy = string(libkg.ParseExpandStrategy(*patch.ExpandStrategy))
- }
- if patch.DevModeEnabled != nil {
- current.DevModeEnabled = *patch.DevModeEnabled
- }
- return current
-}
-
-func (s storedSettings) toMap() map[string]interface{} {
- return map[string]interface{}{
- "web_search_provider": s.WebSearchProvider,
- "brave_api_key": s.BraveAPIKey,
- "exa_api_key": s.ExaAPIKey,
- "brave_country": s.BraveCountry,
- "brave_search_lang": s.BraveSearchLang,
- "exa_user_location": s.ExaUserLocation,
- "expand_strategy": s.ExpandStrategy,
- "dev_mode_enabled": s.DevModeEnabled,
- }
-}
-
-func toPublic(stored storedSettings) *Settings {
- braveMasked := ""
- if stored.BraveAPIKey != "" {
- braveMasked = maskAPIKey(stored.BraveAPIKey)
- }
- exaMasked := ""
- if stored.ExaAPIKey != "" {
- exaMasked = maskAPIKey(stored.ExaAPIKey)
- }
- strategy := string(libkg.ParseExpandStrategy(stored.ExpandStrategy))
- return &Settings{
- WebSearchProvider: string(websearch.ParseProvider(stored.WebSearchProvider)),
- BraveAPIKey: braveMasked,
- BraveAPIKeyConfigured: strings.TrimSpace(stored.BraveAPIKey) != "",
- ExaAPIKey: exaMasked,
- ExaAPIKeyConfigured: strings.TrimSpace(stored.ExaAPIKey) != "",
- BraveCountry: stored.BraveCountry,
- BraveSearchLang: stored.BraveSearchLang,
- ExaUserLocation: stored.ExaUserLocation,
- ExpandStrategy: strategy,
- DevModeEnabled: stored.DevModeEnabled,
- }
-}
-
-func requireActor(tenantID, ownerUID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
- }
- return nil
-}
-
-func isSettingNotFound(err error) bool {
- if err == nil {
- return false
- }
- appErr := app.FromError(err)
- return appErr != nil && strings.Contains(strings.ToLower(appErr.Error()), "not found")
-}
-
-func maskAPIKey(key string) string {
- trimmed := strings.TrimSpace(key)
- if trimmed == "" {
- return ""
- }
- if len(trimmed) <= 4 {
- return "••••"
- }
- return "••••" + trimmed[len(trimmed)-4:]
-}
-
-func isMaskedAPIKey(value string) bool {
- return strings.HasPrefix(value, "••••")
-}
diff --git a/old/backend/internal/model/placement/usecase/settings_dev_mode_test.go b/old/backend/internal/model/placement/usecase/settings_dev_mode_test.go
deleted file mode 100644
index 5dfb81d..0000000
--- a/old/backend/internal/model/placement/usecase/settings_dev_mode_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package usecase
-
-import "testing"
-
-func TestApplyPatchDevModeEnabled(t *testing.T) {
- enabled := true
- disabled := false
- current := defaultSettings()
-
- next := applyPatch(current, SettingsPatch{DevModeEnabled: &enabled})
- if !next.DevModeEnabled {
- t.Fatal("expected dev_mode_enabled true")
- }
-
- next = applyPatch(next, SettingsPatch{DevModeEnabled: &disabled})
- if next.DevModeEnabled {
- t.Fatal("expected dev_mode_enabled false after toggle off")
- }
-}
-
-func TestMergeSettingsDevModeEnabled(t *testing.T) {
- merged := mergeSettings(defaultSettings(), map[string]interface{}{
- "dev_mode_enabled": true,
- })
- if !merged.DevModeEnabled {
- t.Fatal("expected merged dev_mode_enabled true")
- }
-}
diff --git a/old/backend/internal/model/placement_topic/domain/entity/topic.go b/old/backend/internal/model/placement_topic/domain/entity/topic.go
deleted file mode 100644
index 44227f7..0000000
--- a/old/backend/internal/model/placement_topic/domain/entity/topic.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package entity
-
-import brandentity "haixun-backend/internal/model/brand/domain/entity"
-
-const CollectionName = "placement_topics"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusDeleted Status = "deleted"
-)
-
-type Topic struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id"`
- TopicName string `bson:"topic_name,omitempty"`
- SeedQuery string `bson:"seed_query,omitempty"`
- Brief string `bson:"brief,omitempty"`
- ProductID string `bson:"product_id,omitempty"`
- ResearchMap brandentity.ResearchMap `bson:"research_map,omitempty"`
- Status Status `bson:"status"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
-
-func (t Topic) HasPlacementSignals() bool {
- return t.TopicName != "" ||
- t.SeedQuery != "" ||
- t.Brief != "" ||
- !t.ResearchMap.IsEmpty()
-}
diff --git a/old/backend/internal/model/placement_topic/domain/repository/repository.go b/old/backend/internal/model/placement_topic/domain/repository/repository.go
deleted file mode 100644
index 66ff906..0000000
--- a/old/backend/internal/model/placement_topic/domain/repository/repository.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/placement_topic/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, topic *entity.Topic) (*entity.Topic, error)
- FindByID(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error)
- ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Topic, error)
- ListByBrand(ctx context.Context, tenantID, ownerUID, brandID string) ([]*entity.Topic, error)
- Update(ctx context.Context, tenantID, ownerUID, topicID string, patch map[string]interface{}) (*entity.Topic, error)
- SoftDelete(ctx context.Context, tenantID, ownerUID, topicID string) error
-}
diff --git a/old/backend/internal/model/placement_topic/domain/usecase/usecase.go b/old/backend/internal/model/placement_topic/domain/usecase/usecase.go
deleted file mode 100644
index 7b461c1..0000000
--- a/old/backend/internal/model/placement_topic/domain/usecase/usecase.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package usecase
-
-import (
- "context"
-
- brandentity "haixun-backend/internal/model/brand/domain/entity"
-)
-
-type TopicSummary struct {
- ID string
- BrandID string
- BrandDisplayName string
- TopicName string
- SeedQuery string
- Brief string
- ProductID string
- ResearchMap brandentity.ResearchMap
- CreateAt int64
- UpdateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicName string
- SeedQuery string
- Brief string
- ProductID string
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- TopicID string
- Patch TopicPatch
-}
-
-type TopicPatch struct {
- BrandID *string
- TopicName *string
- SeedQuery *string
- Brief *string
- ProductID *string
- AudienceSummary *string
- ContentGoal *string
- Questions []string
- QuestionsSet bool
- Pillars []string
- PillarsSet bool
- Exclusions []string
- ExclusionsSet bool
- PatrolKeywords []string
- PatrolKeywordsSet bool
- ResearchMap *brandentity.ResearchMap
-}
-
-type ListResult struct {
- List []TopicSummary
-}
-
-type UseCase interface {
- List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error)
- Create(ctx context.Context, req CreateRequest) (*TopicSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, topicID string) (*TopicSummary, error)
- Update(ctx context.Context, req UpdateRequest) (*TopicSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, topicID string) error
-}
diff --git a/old/backend/internal/model/placement_topic/repository/mongo.go b/old/backend/internal/model/placement_topic/repository/mongo.go
deleted file mode 100644
index 91934cb..0000000
--- a/old/backend/internal/model/placement_topic/repository/mongo.go
+++ /dev/null
@@ -1,164 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/placement_topic/domain/entity"
- domrepo "haixun-backend/internal/model/placement_topic/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, topic *entity.Topic) (*entity.Topic, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- topic.CreateAt = now
- topic.UpdateAt = now
- if topic.Status == "" {
- topic.Status = entity.StatusOpen
- }
- _, err := r.collection.InsertOne(ctx, topic)
- if err != nil {
- return nil, err
- }
- return topic, nil
-}
-
-func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error) {
- return r.findOne(ctx, bson.M{
- "_id": strings.TrimSpace(topicID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "status": entity.StatusOpen,
- })
-}
-
-func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Topic, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Topic
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) ListByBrand(ctx context.Context, tenantID, ownerUID, brandID string) ([]*entity.Topic, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "brand_id": strings.TrimSpace(brandID),
- "status": entity.StatusOpen,
- },
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Topic
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, topicID string, patch map[string]interface{}) (*entity.Topic, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if len(patch) == 0 {
- return r.FindByID(ctx, tenantID, ownerUID, topicID)
- }
- patch["update_at"] = clock.NowUnixNano()
- var out entity.Topic
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": topicID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": patch},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("placement topic not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, topicID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"_id": topicID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.Brand).ResNotFound("placement topic not found")
- }
- return nil
-}
-
-func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Topic, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- var out entity.Topic
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("placement topic not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/placement_topic/usecase/usecase.go b/old/backend/internal/model/placement_topic/usecase/usecase.go
deleted file mode 100644
index b471ea4..0000000
--- a/old/backend/internal/model/placement_topic/usecase/usecase.go
+++ /dev/null
@@ -1,400 +0,0 @@
-package usecase
-
-import (
- "context"
- "reflect"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- brandentity "haixun-backend/internal/model/brand/domain/entity"
- brandrepo "haixun-backend/internal/model/brand/domain/repository"
- kgdomusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- "haixun-backend/internal/model/placement_topic/domain/entity"
- domrepo "haixun-backend/internal/model/placement_topic/domain/repository"
- domusecase "haixun-backend/internal/model/placement_topic/domain/usecase"
- scanpostrepo "haixun-backend/internal/model/scan_post/domain/repository"
-
- "github.com/google/uuid"
-)
-
-type topicUseCase struct {
- repo domrepo.Repository
- brandRepo brandrepo.Repository
- kg kgdomusecase.UseCase
- scanRepo scanpostrepo.Repository
-}
-
-func NewUseCase(
- repo domrepo.Repository,
- brandRepo brandrepo.Repository,
- kg kgdomusecase.UseCase,
- scanRepo scanpostrepo.Repository,
-) domusecase.UseCase {
- return &topicUseCase{repo: repo, brandRepo: brandRepo, kg: kg, scanRepo: scanRepo}
-}
-
-func (u *topicUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if err := u.migrateLegacyBrands(ctx, tenantID, ownerUID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- brandNames, err := u.brandDisplayNames(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- list := make([]domusecase.TopicSummary, 0, len(items))
- for _, item := range items {
- list = append(list, toSummary(item, brandNames[item.BrandID]))
- }
- return &domusecase.ListResult{List: list}, nil
-}
-
-func (u *topicUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.TopicSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- brandID := strings.TrimSpace(req.BrandID)
- if brandID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("brand_id is required")
- }
- topicName := strings.TrimSpace(req.TopicName)
- seedQuery := strings.TrimSpace(req.SeedQuery)
- brief := strings.TrimSpace(req.Brief)
- if topicName == "" {
- return nil, app.For(code.Brand).InputMissingRequired("topic_name is required")
- }
- if seedQuery == "" || brief == "" {
- return nil, app.For(code.Brand).InputMissingRequired("seed_query and brief are required")
- }
- brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID)
- if err != nil {
- return nil, err
- }
- productID := strings.TrimSpace(req.ProductID)
- if productID != "" && !brandHasProduct(brand, productID) {
- return nil, app.For(code.Brand).InputMissingRequired("product not found on brand")
- }
- topic := &entity.Topic{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- BrandID: brandID,
- TopicName: topicName,
- SeedQuery: seedQuery,
- Brief: brief,
- ProductID: productID,
- Status: entity.StatusOpen,
- }
- item, err := u.repo.Create(ctx, topic)
- if err != nil {
- return nil, err
- }
- summary := toSummary(item, brand.DisplayName)
- return &summary, nil
-}
-
-func (u *topicUseCase) Get(ctx context.Context, tenantID, ownerUID, topicID string) (*domusecase.TopicSummary, error) {
- if err := u.migrateLegacyBrands(ctx, tenantID, ownerUID); err != nil {
- return nil, err
- }
- item, err := u.assertOwned(ctx, tenantID, ownerUID, topicID)
- if err != nil {
- return nil, err
- }
- displayName := ""
- var brand *brandentity.Brand
- if loaded, err := u.brandRepo.FindByID(ctx, tenantID, ownerUID, item.BrandID); err == nil && loaded != nil {
- brand = loaded
- displayName = brand.DisplayName
- }
- summary := toSummary(item, displayName)
- if summary.ResearchMap.IsEmpty() && brand != nil && !brand.ResearchMap.IsEmpty() {
- summary.ResearchMap = brand.ResearchMap
- }
- return &summary, nil
-}
-
-func (u *topicUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.TopicSummary, error) {
- topic, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.TopicID)
- if err != nil {
- return nil, err
- }
- if req.Patch.BrandID != nil {
- brandID := strings.TrimSpace(*req.Patch.BrandID)
- if brandID == "" {
- return nil, app.For(code.Brand).InputMissingRequired("brand_id is required")
- }
- if _, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID); err != nil {
- return nil, err
- }
- }
- if req.Patch.ProductID != nil {
- productID := strings.TrimSpace(*req.Patch.ProductID)
- if productID != "" {
- brandID := topic.BrandID
- if req.Patch.BrandID != nil {
- brandID = strings.TrimSpace(*req.Patch.BrandID)
- }
- brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, brandID)
- if err != nil {
- return nil, err
- }
- if !brandHasProduct(brand, productID) {
- return nil, app.For(code.Brand).InputMissingRequired("product not found on brand")
- }
- }
- }
- patch := patchToMap(req.Patch)
- item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, req.TopicID, patch)
- if err != nil {
- return nil, err
- }
- displayName := ""
- if brand, err := u.brandRepo.FindByID(ctx, req.TenantID, req.OwnerUID, item.BrandID); err == nil && brand != nil {
- displayName = brand.DisplayName
- }
- summary := toSummary(item, displayName)
- return &summary, nil
-}
-
-func (u *topicUseCase) Delete(ctx context.Context, tenantID, ownerUID, topicID string) error {
- if _, err := u.assertOwned(ctx, tenantID, ownerUID, topicID); err != nil {
- return err
- }
- return u.repo.SoftDelete(ctx, tenantID, ownerUID, topicID)
-}
-
-func (u *topicUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, topicID string) (*entity.Topic, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if strings.TrimSpace(topicID) == "" {
- return nil, app.For(code.Brand).InputMissingRequired("topic id is required")
- }
- return u.repo.FindByID(ctx, tenantID, ownerUID, topicID)
-}
-
-func (u *topicUseCase) migrateLegacyBrands(ctx context.Context, tenantID, ownerUID string) error {
- if u.brandRepo == nil {
- return nil
- }
- brands, err := u.brandRepo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- for _, brand := range brands {
- if !legacyBrandHasTopicSignals(brand) {
- continue
- }
- existing, err := u.repo.ListByBrand(ctx, tenantID, ownerUID, brand.ID)
- if err != nil {
- return err
- }
- if legacyTopicAlreadyMigrated(brand, existing) {
- continue
- }
- topic := &entity.Topic{
- ID: uuid.NewString(),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: brand.ID,
- TopicName: legacyTopicName(brand),
- SeedQuery: strings.TrimSpace(brand.SeedQuery),
- Brief: strings.TrimSpace(brand.Brief),
- ProductID: strings.TrimSpace(brand.ProductID),
- ResearchMap: brand.ResearchMap,
- Status: entity.StatusOpen,
- }
- created, err := u.repo.Create(ctx, topic)
- if err != nil {
- return err
- }
- if u.kg != nil {
- _ = u.kg.AttachTopicID(ctx, tenantID, ownerUID, brand.ID, created.ID)
- }
- if u.scanRepo != nil {
- _ = u.scanRepo.AttachTopicID(ctx, tenantID, ownerUID, brand.ID, created.ID)
- }
- _, _ = u.brandRepo.Update(ctx, tenantID, ownerUID, brand.ID, map[string]interface{}{
- "topic_name": "",
- "seed_query": "",
- "brief": "",
- "product_id": "",
- "research_map": brandentity.ResearchMap{},
- })
- }
- return nil
-}
-
-func (u *topicUseCase) brandDisplayNames(ctx context.Context, tenantID, ownerUID string) (map[string]string, error) {
- brands, err := u.brandRepo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- out := make(map[string]string, len(brands))
- for _, brand := range brands {
- out[brand.ID] = brand.DisplayName
- }
- return out, nil
-}
-
-func legacyBrandHasTopicSignals(brand *brandentity.Brand) bool {
- if brand == nil {
- return false
- }
- return strings.TrimSpace(brand.TopicName) != "" ||
- strings.TrimSpace(brand.SeedQuery) != "" ||
- strings.TrimSpace(brand.Brief) != "" ||
- !brand.ResearchMap.IsEmpty()
-}
-
-func legacyTopicAlreadyMigrated(brand *brandentity.Brand, existing []*entity.Topic) bool {
- if brand == nil {
- return true
- }
- legacyName := strings.TrimSpace(brand.TopicName)
- legacySeed := strings.TrimSpace(brand.SeedQuery)
- legacyBrief := strings.TrimSpace(brand.Brief)
- legacyProductID := strings.TrimSpace(brand.ProductID)
- for _, item := range existing {
- if item == nil {
- continue
- }
- sameFields := strings.TrimSpace(item.TopicName) == legacyName &&
- strings.TrimSpace(item.SeedQuery) == legacySeed &&
- strings.TrimSpace(item.Brief) == legacyBrief &&
- strings.TrimSpace(item.ProductID) == legacyProductID
- if sameFields {
- return true
- }
- if legacyName == "" && legacySeed == "" && legacyBrief == "" &&
- !brand.ResearchMap.IsEmpty() &&
- reflect.DeepEqual(item.ResearchMap, brand.ResearchMap) {
- return true
- }
- }
- return false
-}
-
-func legacyTopicName(brand *brandentity.Brand) string {
- if brand == nil {
- return ""
- }
- if name := strings.TrimSpace(brand.TopicName); name != "" {
- return name
- }
- for _, pillar := range brand.ResearchMap.Pillars {
- if name := strings.TrimSpace(pillar); name != "" {
- return name
- }
- }
- for _, question := range brand.ResearchMap.Questions {
- if name := strings.TrimSpace(question); name != "" {
- return name
- }
- }
- return strings.TrimSpace(brand.DisplayName)
-}
-
-func brandHasProduct(brand *brandentity.Brand, productID string) bool {
- if brand == nil {
- return false
- }
- for _, product := range brand.Products {
- if product.ID == productID {
- return true
- }
- }
- return false
-}
-
-func requireActor(tenantID, ownerUID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required")
- }
- return nil
-}
-
-func toSummary(item *entity.Topic, brandDisplayName string) domusecase.TopicSummary {
- if item == nil {
- return domusecase.TopicSummary{}
- }
- return domusecase.TopicSummary{
- ID: item.ID,
- BrandID: item.BrandID,
- BrandDisplayName: brandDisplayName,
- TopicName: item.TopicName,
- SeedQuery: item.SeedQuery,
- Brief: item.Brief,
- ProductID: item.ProductID,
- ResearchMap: item.ResearchMap,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func patchToMap(patch domusecase.TopicPatch) map[string]interface{} {
- out := map[string]interface{}{}
- if patch.BrandID != nil {
- out["brand_id"] = strings.TrimSpace(*patch.BrandID)
- }
- if patch.TopicName != nil {
- out["topic_name"] = strings.TrimSpace(*patch.TopicName)
- }
- if patch.SeedQuery != nil {
- out["seed_query"] = strings.TrimSpace(*patch.SeedQuery)
- }
- if patch.Brief != nil {
- out["brief"] = strings.TrimSpace(*patch.Brief)
- }
- if patch.ProductID != nil {
- out["product_id"] = strings.TrimSpace(*patch.ProductID)
- }
- if patch.AudienceSummary != nil {
- out["research_map.audience_summary"] = strings.TrimSpace(*patch.AudienceSummary)
- }
- if patch.ContentGoal != nil {
- out["research_map.content_goal"] = strings.TrimSpace(*patch.ContentGoal)
- }
- if patch.QuestionsSet {
- out["research_map.questions"] = cleanStringList(patch.Questions)
- }
- if patch.PillarsSet {
- out["research_map.pillars"] = cleanStringList(patch.Pillars)
- }
- if patch.ExclusionsSet {
- out["research_map.exclusions"] = cleanStringList(patch.Exclusions)
- }
- if patch.ResearchMap != nil {
- out["research_map"] = *patch.ResearchMap
- }
- if patch.PatrolKeywordsSet {
- out["research_map.patrol_keywords"] = libkg.NormalizePatrolKeywordList(patch.PatrolKeywords)
- }
- return out
-}
-
-func cleanStringList(items []string) []string {
- out := make([]string, 0, len(items))
- seen := make(map[string]struct{}, len(items))
- for _, item := range items {
- trimmed := strings.TrimSpace(item)
- if trimmed == "" {
- continue
- }
- if _, ok := seen[trimmed]; ok {
- continue
- }
- seen[trimmed] = struct{}{}
- out = append(out, trimmed)
- }
- return out
-}
diff --git a/old/backend/internal/model/publish_analytics/domain/entity/analytics.go b/old/backend/internal/model/publish_analytics/domain/entity/analytics.go
deleted file mode 100644
index 9e8e39d..0000000
--- a/old/backend/internal/model/publish_analytics/domain/entity/analytics.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package entity
-
-const CollectionName = "publish_analytics"
-
-type PublishAnalytics struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id,omitempty"`
- PersonaID string `bson:"persona_id,omitempty"`
- MediaID string `bson:"media_id"`
- Permalink string `bson:"permalink,omitempty"`
- PublishedAt int64 `bson:"published_at"`
- Checkpoint string `bson:"checkpoint"`
- LikeCount int `bson:"like_count,omitempty"`
- ReplyCount int `bson:"reply_count,omitempty"`
- RepostCount int `bson:"repost_count,omitempty"`
- QuoteCount int `bson:"quote_count,omitempty"`
- ViewCount int `bson:"view_count,omitempty"`
- ShareCount int `bson:"share_count,omitempty"`
- ImpressionCount int `bson:"impression_count,omitempty"`
- ReachCount int `bson:"reach_count,omitempty"`
- CheckedAt int64 `bson:"checked_at"`
- CreateAt int64 `bson:"create_at"`
-}
-
-const (
- Checkpoint1h = "+1h"
- Checkpoint24h = "+24h"
- Checkpoint7d = "+7d"
-)
diff --git a/old/backend/internal/model/publish_analytics/domain/repository/repository.go b/old/backend/internal/model/publish_analytics/domain/repository/repository.go
deleted file mode 100644
index b54dd32..0000000
--- a/old/backend/internal/model/publish_analytics/domain/repository/repository.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/publish_analytics/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, analytics *entity.PublishAnalytics) error
- Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error)
- ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error)
- Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error)
- DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error)
-}
diff --git a/old/backend/internal/model/publish_analytics/domain/usecase/usecase.go b/old/backend/internal/model/publish_analytics/domain/usecase/usecase.go
deleted file mode 100644
index 6331a48..0000000
--- a/old/backend/internal/model/publish_analytics/domain/usecase/usecase.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package usecase
-
-import "context"
-
-type PublishAnalyticsSummary struct {
- ID string
- MediaID string
- Permalink string
- PublishedAt int64
- Checkpoint string
- LikeCount int
- ReplyCount int
- RepostCount int
- QuoteCount int
- ViewCount int
- ShareCount int
- ImpressionCount int
- ReachCount int
- CheckedAt int64
- CreateAt int64
-}
-
-type ScheduleCheckpointsRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- MediaID string
- Permalink string
- PublishedAt int64
-}
-
-type UseCase interface {
- RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary PublishAnalyticsSummary) (*PublishAnalyticsSummary, error)
- ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]PublishAnalyticsSummary, error)
- ScheduleCheckpoints(ctx context.Context, req ScheduleCheckpointsRequest) error
-}
diff --git a/old/backend/internal/model/publish_analytics/repository/mongo.go b/old/backend/internal/model/publish_analytics/repository/mongo.go
deleted file mode 100644
index 29d78ae..0000000
--- a/old/backend/internal/model/publish_analytics/repository/mongo.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_analytics/domain/entity"
- domrepo "haixun-backend/internal/model/publish_analytics/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "media_id", Value: 1},
- {Key: "checkpoint", Value: 1},
- },
- },
- })
- return err
-}
-
-func actorFilter(tenantID, ownerUID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- }
-}
-
-func (r *mongoRepository) Create(ctx context.Context, analytics *entity.PublishAnalytics) error {
- if r.collection == nil {
- return app.For(code.AI).DBUnavailable("Mongo is not configured")
- }
- if analytics == nil {
- return app.For(code.AI).InputMissingRequired("analytics is required")
- }
- _, err := r.collection.InsertOne(ctx, analytics)
- return err
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, analyticsID string) (*entity.PublishAnalytics, error) {
- if r.collection == nil {
- return nil, app.For(code.AI).DBUnavailable("Mongo is not configured")
- }
- analyticsID = strings.TrimSpace(analyticsID)
- if analyticsID == "" {
- return nil, app.For(code.AI).InputMissingRequired("analytics_id is required")
- }
- filter := actorFilter(tenantID, ownerUID)
- filter["_id"] = analyticsID
- var out entity.PublishAnalytics
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.AI).ResNotFound("publish analytics not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]entity.PublishAnalytics, error) {
- if r.collection == nil {
- return nil, app.For(code.AI).DBUnavailable("Mongo is not configured")
- }
- mediaID = strings.TrimSpace(mediaID)
- if mediaID == "" {
- return nil, app.For(code.AI).InputMissingRequired("media_id is required")
- }
- filter := actorFilter(tenantID, ownerUID)
- filter["media_id"] = mediaID
- opts := options.Find().SetSort(bson.D{{Key: "checked_at", Value: -1}})
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.PublishAnalytics
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, analytics *entity.PublishAnalytics) (*entity.PublishAnalytics, error) {
- if r.collection == nil {
- return nil, app.For(code.AI).DBUnavailable("Mongo is not configured")
- }
- if analytics == nil {
- return nil, app.For(code.AI).InputMissingRequired("analytics is required")
- }
- if analytics.ID == "" {
- return nil, app.For(code.AI).InputMissingRequired("analytics id is required")
- }
-
- filter := actorFilter(analytics.TenantID, analytics.OwnerUID)
- filter["media_id"] = strings.TrimSpace(analytics.MediaID)
- filter["checkpoint"] = strings.TrimSpace(analytics.Checkpoint)
- opts := options.Replace().SetUpsert(true)
- _, err := r.collection.ReplaceOne(ctx, filter, analytics, opts)
- if err != nil {
- return nil, err
- }
- return analytics, nil
-}
-
-func (r *mongoRepository) DeleteByMediaIDs(ctx context.Context, tenantID, ownerUID string, mediaIDs []string) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.AI).DBUnavailable("Mongo is not configured")
- }
- ids := make([]string, 0, len(mediaIDs))
- seen := map[string]struct{}{}
- for _, mediaID := range mediaIDs {
- trimmed := strings.TrimSpace(mediaID)
- if trimmed == "" {
- continue
- }
- if _, ok := seen[trimmed]; ok {
- continue
- }
- seen[trimmed] = struct{}{}
- ids = append(ids, trimmed)
- }
- if len(ids) == 0 {
- return 0, nil
- }
- filter := actorFilter(tenantID, ownerUID)
- filter["media_id"] = bson.M{"$in": ids}
- res, err := r.collection.DeleteMany(ctx, filter)
- if err != nil {
- return 0, err
- }
- return res.DeletedCount, nil
-}
diff --git a/old/backend/internal/model/publish_analytics/usecase/schedule.go b/old/backend/internal/model/publish_analytics/usecase/schedule.go
deleted file mode 100644
index 3b33e06..0000000
--- a/old/backend/internal/model/publish_analytics/usecase/schedule.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
- "time"
-
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/model/publish_analytics/domain/entity"
- domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase"
-)
-
-const publishAnalyticsTemplateType = "publish-analytics"
-
-func (u *publishAnalyticsUseCase) ScheduleCheckpoints(ctx context.Context, req domusecase.ScheduleCheckpointsRequest) error {
- if u.jobs == nil {
- return nil
- }
- tenantID := strings.TrimSpace(req.TenantID)
- ownerUID := strings.TrimSpace(req.OwnerUID)
- accountID := strings.TrimSpace(req.AccountID)
- mediaID := strings.TrimSpace(req.MediaID)
- if tenantID == "" || ownerUID == "" || accountID == "" || mediaID == "" {
- return nil
- }
- publishedAt := req.PublishedAt
- if publishedAt <= 0 {
- publishedAt = time.Now().UnixNano()
- }
- permalink := strings.TrimSpace(req.Permalink)
- checkpoints := []struct {
- name string
- delay time.Duration
- }{
- {entity.Checkpoint1h, time.Hour},
- {entity.Checkpoint24h, 24 * time.Hour},
- {entity.Checkpoint7d, 7 * 24 * time.Hour},
- }
- for _, cp := range checkpoints {
- at := publishedAt + cp.delay.Nanoseconds()
- scheduled := at
- _, _ = u.jobs.CreateRun(ctx, jobdom.CreateRunRequest{
- TemplateType: publishAnalyticsTemplateType,
- Scope: "threads_account",
- ScopeID: accountID,
- TenantID: tenantID,
- OwnerUID: ownerUID,
- ScheduledAt: &scheduled,
- Payload: map[string]any{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "account_id": accountID,
- "media_id": mediaID,
- "permalink": permalink,
- "published_at": publishedAt,
- "checkpoint": cp.name,
- },
- })
- }
- return nil
-}
diff --git a/old/backend/internal/model/publish_analytics/usecase/usecase.go b/old/backend/internal/model/publish_analytics/usecase/usecase.go
deleted file mode 100644
index aae8421..0000000
--- a/old/backend/internal/model/publish_analytics/usecase/usecase.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/model/publish_analytics/domain/entity"
- domrepo "haixun-backend/internal/model/publish_analytics/domain/repository"
- domusecase "haixun-backend/internal/model/publish_analytics/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type publishAnalyticsUseCase struct {
- repo domrepo.Repository
- jobs jobdom.UseCase
-}
-
-func NewUseCase(repo domrepo.Repository, jobs jobdom.UseCase) domusecase.UseCase {
- return &publishAnalyticsUseCase{repo: repo, jobs: jobs}
-}
-
-func (u *publishAnalyticsUseCase) RecordCheckpoint(ctx context.Context, tenantID, ownerUID string, summary domusecase.PublishAnalyticsSummary) (*domusecase.PublishAnalyticsSummary, error) {
- tenantID = strings.TrimSpace(tenantID)
- ownerUID = strings.TrimSpace(ownerUID)
- if tenantID == "" || ownerUID == "" {
- return nil, app.For(code.AI).AuthUnauthorized("missing actor")
- }
- mediaID := strings.TrimSpace(summary.MediaID)
- if mediaID == "" {
- return nil, app.For(code.AI).InputMissingRequired("media_id is required")
- }
- checkpoint := strings.TrimSpace(summary.Checkpoint)
- if checkpoint == "" {
- return nil, app.For(code.AI).InputMissingRequired("checkpoint is required")
- }
-
- now := clock.NowUnixNano()
- analytics := &entity.PublishAnalytics{
- ID: uuid.NewString(),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- MediaID: mediaID,
- Permalink: strings.TrimSpace(summary.Permalink),
- PublishedAt: summary.PublishedAt,
- Checkpoint: checkpoint,
- LikeCount: summary.LikeCount,
- ReplyCount: summary.ReplyCount,
- RepostCount: summary.RepostCount,
- QuoteCount: summary.QuoteCount,
- ViewCount: summary.ViewCount,
- ShareCount: summary.ShareCount,
- ImpressionCount: summary.ImpressionCount,
- ReachCount: summary.ReachCount,
- CheckedAt: now,
- CreateAt: now,
- }
-
- created, err := u.repo.Upsert(ctx, analytics)
- if err != nil {
- return nil, err
- }
- out := toSummary(*created)
- return &out, nil
-}
-
-func (u *publishAnalyticsUseCase) ListByMedia(ctx context.Context, tenantID, ownerUID, mediaID string) ([]domusecase.PublishAnalyticsSummary, error) {
- tenantID = strings.TrimSpace(tenantID)
- ownerUID = strings.TrimSpace(ownerUID)
- mediaID = strings.TrimSpace(mediaID)
- if tenantID == "" || ownerUID == "" {
- return nil, app.For(code.AI).AuthUnauthorized("missing actor")
- }
- if mediaID == "" {
- return nil, app.For(code.AI).InputMissingRequired("media_id is required")
- }
-
- items, err := u.repo.ListByMedia(ctx, tenantID, ownerUID, mediaID)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.PublishAnalyticsSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func toSummary(item entity.PublishAnalytics) domusecase.PublishAnalyticsSummary {
- return domusecase.PublishAnalyticsSummary{
- ID: item.ID,
- MediaID: item.MediaID,
- Permalink: item.Permalink,
- PublishedAt: item.PublishedAt,
- Checkpoint: item.Checkpoint,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- RepostCount: item.RepostCount,
- QuoteCount: item.QuoteCount,
- ViewCount: item.ViewCount,
- ShareCount: item.ShareCount,
- ImpressionCount: item.ImpressionCount,
- ReachCount: item.ReachCount,
- CheckedAt: item.CheckedAt,
- CreateAt: item.CreateAt,
- }
-}
diff --git a/old/backend/internal/model/publish_guard/domain/entity/policy.go b/old/backend/internal/model/publish_guard/domain/entity/policy.go
deleted file mode 100644
index 5e703e6..0000000
--- a/old/backend/internal/model/publish_guard/domain/entity/policy.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package entity
-
-const CollectionName = "publish_guard_policies"
-
-type Policy struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- Enabled bool `bson:"enabled"`
- MaxDailyPosts int `bson:"max_daily_posts"`
- MinIntervalMinutes int `bson:"min_interval_minutes"`
- ConsecutiveFailurePauseLimit int `bson:"consecutive_failure_pause_limit"`
- Paused bool `bson:"paused"`
- PausedReason string `bson:"paused_reason,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/publish_guard/domain/repository/repository.go b/old/backend/internal/model/publish_guard/domain/repository/repository.go
deleted file mode 100644
index a958381..0000000
--- a/old/backend/internal/model/publish_guard/domain/repository/repository.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/publish_guard/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error)
- Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error)
- DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
-}
diff --git a/old/backend/internal/model/publish_guard/domain/usecase/usecase.go b/old/backend/internal/model/publish_guard/domain/usecase/usecase.go
deleted file mode 100644
index 4ec89b7..0000000
--- a/old/backend/internal/model/publish_guard/domain/usecase/usecase.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package usecase
-
-import "context"
-
-type PolicySummary struct {
- AccountID string
- Enabled bool
- MaxDailyPosts int
- MinIntervalMinutes int
- ConsecutiveFailurePauseLimit int
- Paused bool
- PausedReason string
- UpdateAt int64
-}
-
-type UpsertRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Enabled bool
- MaxDailyPosts int
- MinIntervalMinutes int
- ConsecutiveFailurePauseLimit int
- Paused *bool
- PausedReason string
-}
-
-type CheckRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Now int64
- PublishedToday int64
- LastPublishedAt int64
- FailureStreak int
-}
-
-type CheckResult struct {
- Allowed bool
- DelayUntil int64
- Reason string
- ShouldPause bool
-}
-
-type UseCase interface {
- Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
- Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error)
- Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
- Check(ctx context.Context, req CheckRequest) (*CheckResult, error)
- Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*PolicySummary, error)
-}
diff --git a/old/backend/internal/model/publish_guard/repository/mongo.go b/old/backend/internal/model/publish_guard/repository/mongo.go
deleted file mode 100644
index dcc262e..0000000
--- a/old/backend/internal/model/publish_guard/repository/mongo.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_guard/domain/entity"
- domrepo "haixun-backend/internal/model/publish_guard/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}},
- Options: options.Index().SetUnique(true),
- })
- return err
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Policy
- err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("publish guard policy not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if policy == nil {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required")
- }
- opts := options.Replace().SetUpsert(true)
- _, err := r.collection.ReplaceOne(ctx, actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID), policy, opts)
- if err != nil {
- return nil, err
- }
- return policy, nil
-}
-
-func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID))
- return err
-}
-
-func actorFilter(tenantID, ownerUID, accountID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "account_id": strings.TrimSpace(accountID),
- }
-}
diff --git a/old/backend/internal/model/publish_guard/usecase/usecase.go b/old/backend/internal/model/publish_guard/usecase/usecase.go
deleted file mode 100644
index e7777b6..0000000
--- a/old/backend/internal/model/publish_guard/usecase/usecase.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
- "time"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_guard/domain/entity"
- domrepo "haixun-backend/internal/model/publish_guard/domain/repository"
- domusecase "haixun-backend/internal/model/publish_guard/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type guardUseCase struct {
- repo domrepo.Repository
- threadsAccount threadsaccountdomain.UseCase
-}
-
-func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase {
- return &guardUseCase{repo: repo, threadsAccount: threadsAccount}
-}
-
-func (u *guardUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
- if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- if strings.Contains(err.Error(), "not found") {
- item = defaultPolicy(tenantID, ownerUID, accountID)
- } else {
- return nil, err
- }
- }
- out := toSummary(item)
- return &out, nil
-}
-
-func (u *guardUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) {
- if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
- return nil, err
- }
- item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID)
- item.Enabled = req.Enabled
- if req.MaxDailyPosts > 0 {
- item.MaxDailyPosts = req.MaxDailyPosts
- }
- if req.MinIntervalMinutes > 0 {
- item.MinIntervalMinutes = req.MinIntervalMinutes
- }
- if req.ConsecutiveFailurePauseLimit > 0 {
- item.ConsecutiveFailurePauseLimit = req.ConsecutiveFailurePauseLimit
- }
- if req.Paused != nil {
- item.Paused = *req.Paused
- }
- item.PausedReason = strings.TrimSpace(req.PausedReason)
- item.UpdateAt = clock.NowUnixNano()
- saved, err := u.repo.Upsert(ctx, item)
- if err != nil {
- return nil, err
- }
- out := toSummary(saved)
- return &out, nil
-}
-
-func (u *guardUseCase) Resume(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
- policy, err := u.Get(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- paused := false
- return u.Upsert(ctx, domusecase.UpsertRequest{
- TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled,
- MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes,
- ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit,
- Paused: &paused,
- })
-}
-
-func (u *guardUseCase) Pause(ctx context.Context, tenantID, ownerUID, accountID, reason string) (*domusecase.PolicySummary, error) {
- policy, err := u.Get(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- paused := true
- return u.Upsert(ctx, domusecase.UpsertRequest{
- TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID, Enabled: policy.Enabled,
- MaxDailyPosts: policy.MaxDailyPosts, MinIntervalMinutes: policy.MinIntervalMinutes,
- ConsecutiveFailurePauseLimit: policy.ConsecutiveFailurePauseLimit,
- Paused: &paused, PausedReason: reason,
- })
-}
-
-func (u *guardUseCase) Check(ctx context.Context, req domusecase.CheckRequest) (*domusecase.CheckResult, error) {
- policy, err := u.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID)
- if err != nil {
- return nil, err
- }
- if !policy.Enabled {
- return &domusecase.CheckResult{Allowed: true}, nil
- }
- now := req.Now
- if now <= 0 {
- now = clock.NowUnixNano()
- }
- if policy.Paused {
- return &domusecase.CheckResult{Allowed: false, Reason: firstNonEmpty(policy.PausedReason, "發文已暫停")}, nil
- }
- if policy.MaxDailyPosts > 0 && req.PublishedToday >= int64(policy.MaxDailyPosts) {
- return &domusecase.CheckResult{Allowed: false, DelayUntil: now + int64(time.Hour), Reason: "已達每日發文上限"}, nil
- }
- if policy.MinIntervalMinutes > 0 && req.LastPublishedAt > 0 {
- minNext := req.LastPublishedAt + int64(time.Duration(policy.MinIntervalMinutes)*time.Minute)
- if minNext > now {
- return &domusecase.CheckResult{Allowed: false, DelayUntil: minNext, Reason: "未達最小發文間隔"}, nil
- }
- }
- if policy.ConsecutiveFailurePauseLimit > 0 && req.FailureStreak >= policy.ConsecutiveFailurePauseLimit {
- return &domusecase.CheckResult{Allowed: false, Reason: "連續失敗達上限,已暫停帳號發文", ShouldPause: true}, nil
- }
- return &domusecase.CheckResult{Allowed: true}, nil
-}
-
-func (u *guardUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(accountID) == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- if u.threadsAccount != nil {
- _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID)
- return err
- }
- return nil
-}
-
-func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy {
- now := clock.NowUnixNano()
- return &entity.Policy{
- ID: uuid.NewString(), TenantID: strings.TrimSpace(tenantID), OwnerUID: strings.TrimSpace(ownerUID), AccountID: strings.TrimSpace(accountID),
- Enabled: true, MaxDailyPosts: 6, MinIntervalMinutes: 90, ConsecutiveFailurePauseLimit: 3,
- CreateAt: now, UpdateAt: now,
- }
-}
-
-func toSummary(item *entity.Policy) domusecase.PolicySummary {
- if item == nil {
- return domusecase.PolicySummary{}
- }
- return domusecase.PolicySummary{
- AccountID: item.AccountID, Enabled: item.Enabled, MaxDailyPosts: item.MaxDailyPosts,
- MinIntervalMinutes: item.MinIntervalMinutes, ConsecutiveFailurePauseLimit: item.ConsecutiveFailurePauseLimit,
- Paused: item.Paused, PausedReason: item.PausedReason, UpdateAt: item.UpdateAt,
- }
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
-}
diff --git a/old/backend/internal/model/publish_guard/usecase/usecase_test.go b/old/backend/internal/model/publish_guard/usecase/usecase_test.go
deleted file mode 100644
index 6c829ad..0000000
--- a/old/backend/internal/model/publish_guard/usecase/usecase_test.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package usecase
-
-import (
- "context"
- "testing"
- "time"
-
- "haixun-backend/internal/model/publish_guard/domain/entity"
- domusecase "haixun-backend/internal/model/publish_guard/domain/usecase"
-)
-
-type guardRepoStub struct {
- policy *entity.Policy
-}
-
-func (r *guardRepoStub) EnsureIndexes(ctx context.Context) error { return nil }
-func (r *guardRepoStub) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
- if r.policy == nil {
- r.policy = defaultPolicy(tenantID, ownerUID, accountID)
- }
- return r.policy, nil
-}
-func (r *guardRepoStub) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
- r.policy = policy
- return policy, nil
-}
-func (r *guardRepoStub) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
- r.policy = nil
- return nil
-}
-
-func TestGuardCheckBlocksPausedPolicy(t *testing.T) {
- repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
- repo.policy.Paused = true
- repo.policy.PausedReason = "token error"
- uc := NewUseCase(repo, nil)
-
- result, err := uc.Check(context.Background(), checkReq())
- if err != nil {
- t.Fatal(err)
- }
- if result.Allowed || result.Reason != "token error" {
- t.Fatalf("expected paused block, got %#v", result)
- }
-}
-
-func TestGuardCheckDelaysWhenDailyLimitReached(t *testing.T) {
- repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
- repo.policy.MaxDailyPosts = 1
- uc := NewUseCase(repo, nil)
-
- result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) {
- req.publishedToday = 1
- }))
- if err != nil {
- t.Fatal(err)
- }
- if result.Allowed || result.DelayUntil == 0 {
- t.Fatalf("expected delayed daily limit, got %#v", result)
- }
-}
-
-func TestGuardCheckDelaysWhenIntervalNotReached(t *testing.T) {
- repo := &guardRepoStub{policy: defaultPolicy("t", "u", "a")}
- repo.policy.MinIntervalMinutes = 90
- uc := NewUseCase(repo, nil)
-
- now := time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()
- result, err := uc.Check(context.Background(), checkReqWith(func(req *CheckRequestBuilder) {
- req.now = now
- req.lastPublishedAt = now - int64(30*time.Minute)
- }))
- if err != nil {
- t.Fatal(err)
- }
- if result.Allowed || result.DelayUntil <= now {
- t.Fatalf("expected interval delay, got %#v", result)
- }
-}
-
-type CheckRequestBuilder struct {
- now int64
- publishedToday int64
- lastPublishedAt int64
-}
-
-func checkReq() domusecase.CheckRequest {
- return checkReqWith(nil)
-}
-
-func checkReqWith(update func(*CheckRequestBuilder)) domusecase.CheckRequest {
- builder := &CheckRequestBuilder{now: time.Date(2026, 6, 30, 8, 0, 0, 0, time.UTC).UnixNano()}
- if update != nil {
- update(builder)
- }
- return domusecase.CheckRequest{
- TenantID: "t", OwnerUID: "u", AccountID: "a",
- Now: builder.now, PublishedToday: builder.publishedToday, LastPublishedAt: builder.lastPublishedAt,
- }
-}
diff --git a/old/backend/internal/model/publish_inventory/domain/entity/policy.go b/old/backend/internal/model/publish_inventory/domain/entity/policy.go
deleted file mode 100644
index 053d97e..0000000
--- a/old/backend/internal/model/publish_inventory/domain/entity/policy.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package entity
-
-const CollectionName = "publish_inventory_policies"
-
-type Slot struct {
- Weekday int `bson:"weekday" json:"weekday"`
- Time string `bson:"time" json:"time"`
-}
-
-type SourceRef struct {
- Type string `bson:"type" json:"type"`
- PersonaID string `bson:"persona_id,omitempty" json:"persona_id,omitempty"`
- CopyMissionID string `bson:"copy_mission_id,omitempty" json:"copy_mission_id,omitempty"`
- ScanPostID string `bson:"scan_post_id,omitempty" json:"scan_post_id,omitempty"`
- ManualSeed string `bson:"manual_seed,omitempty" json:"manual_seed,omitempty"`
-}
-
-type Policy struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- Enabled bool `bson:"enabled"`
- TargetDailyCount int `bson:"target_daily_count"`
- LowStockThreshold int `bson:"low_stock_threshold"`
- LookaheadDays int `bson:"lookahead_days"`
- Timezone string `bson:"timezone"`
- Slots []Slot `bson:"slots,omitempty"`
- SourceRefs []SourceRef `bson:"source_refs,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/publish_inventory/domain/repository/repository.go b/old/backend/internal/model/publish_inventory/domain/repository/repository.go
deleted file mode 100644
index e55a79b..0000000
--- a/old/backend/internal/model/publish_inventory/domain/repository/repository.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/publish_inventory/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error)
- Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error)
- ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error)
- DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
-}
diff --git a/old/backend/internal/model/publish_inventory/domain/usecase/usecase.go b/old/backend/internal/model/publish_inventory/domain/usecase/usecase.go
deleted file mode 100644
index 68525d3..0000000
--- a/old/backend/internal/model/publish_inventory/domain/usecase/usecase.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package usecase
-
-import "context"
-
-type Slot struct {
- Weekday int
- Time string
-}
-
-type SourceRef struct {
- Type string
- PersonaID string
- CopyMissionID string
- ScanPostID string
- ManualSeed string
-}
-
-type PolicySummary struct {
- AccountID string
- Enabled bool
- TargetDailyCount int
- LowStockThreshold int
- LookaheadDays int
- Timezone string
- Slots []Slot
- SourceRefs []SourceRef
- UpdateAt int64
-}
-
-type UpsertRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Enabled bool
- TargetDailyCount int
- LowStockThreshold int
- LookaheadDays int
- Timezone string
- Slots []Slot
- SourceRefs []SourceRef
-}
-
-type UseCase interface {
- Get(ctx context.Context, tenantID, ownerUID, accountID string) (*PolicySummary, error)
- Upsert(ctx context.Context, req UpsertRequest) (*PolicySummary, error)
- ListEnabled(ctx context.Context, limit int) ([]PolicySummary, error)
-}
diff --git a/old/backend/internal/model/publish_inventory/repository/mongo.go b/old/backend/internal/model/publish_inventory/repository/mongo.go
deleted file mode 100644
index 3edcef1..0000000
--- a/old/backend/internal/model/publish_inventory/repository/mongo.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_inventory/domain/entity"
- domrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- {Keys: bson.D{{Key: "enabled", Value: 1}}},
- })
- return err
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Policy, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Policy
- err := r.collection.FindOne(ctx, actorFilter(tenantID, ownerUID, accountID)).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("publish inventory policy not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, policy *entity.Policy) (*entity.Policy, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if policy == nil {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("policy is required")
- }
- filter := actorFilter(policy.TenantID, policy.OwnerUID, policy.AccountID)
- opts := options.Replace().SetUpsert(true)
- if _, err := r.collection.ReplaceOne(ctx, filter, policy, opts); err != nil {
- return nil, err
- }
- return policy, nil
-}
-
-func (r *mongoRepository) ListEnabled(ctx context.Context, limit int) ([]entity.Policy, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 100
- }
- cur, err := r.collection.Find(ctx, bson.M{"enabled": true}, options.Find().SetLimit(int64(limit)))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.Policy
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteOne(ctx, actorFilter(tenantID, ownerUID, accountID))
- return err
-}
-
-func actorFilter(tenantID, ownerUID, accountID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "account_id": strings.TrimSpace(accountID),
- }
-}
diff --git a/old/backend/internal/model/publish_inventory/usecase/usecase.go b/old/backend/internal/model/publish_inventory/usecase/usecase.go
deleted file mode 100644
index 497c412..0000000
--- a/old/backend/internal/model/publish_inventory/usecase/usecase.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_inventory/domain/entity"
- domrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
- domusecase "haixun-backend/internal/model/publish_inventory/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type inventoryUseCase struct {
- repo domrepo.Repository
- threadsAccount threadsaccountdomain.UseCase
-}
-
-func NewUseCase(repo domrepo.Repository, threadsAccount threadsaccountdomain.UseCase) domusecase.UseCase {
- return &inventoryUseCase{repo: repo, threadsAccount: threadsAccount}
-}
-
-func (u *inventoryUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.PolicySummary, error) {
- if err := u.require(ctx, tenantID, ownerUID, accountID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- if strings.Contains(err.Error(), "not found") {
- item = defaultPolicy(tenantID, ownerUID, accountID)
- } else {
- return nil, err
- }
- }
- out := toSummary(item)
- return &out, nil
-}
-
-func (u *inventoryUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PolicySummary, error) {
- if err := u.require(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
- return nil, err
- }
- item := defaultPolicy(req.TenantID, req.OwnerUID, req.AccountID)
- item.Enabled = req.Enabled
- if req.TargetDailyCount > 0 {
- item.TargetDailyCount = req.TargetDailyCount
- }
- if req.LowStockThreshold > 0 {
- item.LowStockThreshold = req.LowStockThreshold
- }
- if req.LookaheadDays > 0 {
- item.LookaheadDays = req.LookaheadDays
- }
- if strings.TrimSpace(req.Timezone) != "" {
- item.Timezone = strings.TrimSpace(req.Timezone)
- }
- item.Slots = slotsToEntity(req.Slots)
- item.SourceRefs = sourceRefsToEntity(req.SourceRefs)
- now := clock.NowUnixNano()
- item.UpdateAt = now
- saved, err := u.repo.Upsert(ctx, item)
- if err != nil {
- return nil, err
- }
- out := toSummary(saved)
- return &out, nil
-}
-
-func (u *inventoryUseCase) ListEnabled(ctx context.Context, limit int) ([]domusecase.PolicySummary, error) {
- items, err := u.repo.ListEnabled(ctx, limit)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.PolicySummary, 0, len(items))
- for _, item := range items {
- summary := toSummary(&item)
- out = append(out, summary)
- }
- return out, nil
-}
-
-func (u *inventoryUseCase) require(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(accountID) == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- if u.threadsAccount != nil {
- _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID)
- return err
- }
- return nil
-}
-
-func defaultPolicy(tenantID, ownerUID, accountID string) *entity.Policy {
- now := clock.NowUnixNano()
- return &entity.Policy{
- ID: uuid.NewString(),
- TenantID: strings.TrimSpace(tenantID),
- OwnerUID: strings.TrimSpace(ownerUID),
- AccountID: strings.TrimSpace(accountID),
- Enabled: false,
- TargetDailyCount: 2,
- LowStockThreshold: 3,
- LookaheadDays: 7,
- Timezone: "Asia/Taipei",
- Slots: []entity.Slot{
- {Weekday: 1, Time: "09:30"},
- {Weekday: 3, Time: "12:30"},
- {Weekday: 5, Time: "18:30"},
- },
- CreateAt: now,
- UpdateAt: now,
- }
-}
-
-func toSummary(item *entity.Policy) domusecase.PolicySummary {
- if item == nil {
- return domusecase.PolicySummary{}
- }
- return domusecase.PolicySummary{
- AccountID: item.AccountID, Enabled: item.Enabled, TargetDailyCount: item.TargetDailyCount,
- LowStockThreshold: item.LowStockThreshold, LookaheadDays: item.LookaheadDays,
- Timezone: item.Timezone, Slots: slotsFromEntity(item.Slots), SourceRefs: sourceRefsFromEntity(item.SourceRefs), UpdateAt: item.UpdateAt,
- }
-}
-
-func slotsToEntity(items []domusecase.Slot) []entity.Slot {
- out := make([]entity.Slot, 0, len(items))
- for _, item := range items {
- if strings.TrimSpace(item.Time) == "" {
- continue
- }
- out = append(out, entity.Slot{Weekday: item.Weekday, Time: strings.TrimSpace(item.Time)})
- }
- return out
-}
-
-func slotsFromEntity(items []entity.Slot) []domusecase.Slot {
- out := make([]domusecase.Slot, 0, len(items))
- for _, item := range items {
- out = append(out, domusecase.Slot{Weekday: item.Weekday, Time: item.Time})
- }
- return out
-}
-
-func sourceRefsToEntity(items []domusecase.SourceRef) []entity.SourceRef {
- out := make([]entity.SourceRef, 0, len(items))
- for _, item := range items {
- if strings.TrimSpace(item.Type) == "" {
- continue
- }
- out = append(out, entity.SourceRef{
- Type: strings.TrimSpace(item.Type), PersonaID: strings.TrimSpace(item.PersonaID),
- CopyMissionID: strings.TrimSpace(item.CopyMissionID), ScanPostID: strings.TrimSpace(item.ScanPostID), ManualSeed: strings.TrimSpace(item.ManualSeed),
- })
- }
- return out
-}
-
-func sourceRefsFromEntity(items []entity.SourceRef) []domusecase.SourceRef {
- out := make([]domusecase.SourceRef, 0, len(items))
- for _, item := range items {
- out = append(out, domusecase.SourceRef{
- Type: item.Type, PersonaID: item.PersonaID, CopyMissionID: item.CopyMissionID, ScanPostID: item.ScanPostID, ManualSeed: item.ManualSeed,
- })
- }
- return out
-}
diff --git a/old/backend/internal/model/publish_queue/domain/entity/queue_item.go b/old/backend/internal/model/publish_queue/domain/entity/queue_item.go
deleted file mode 100644
index 7c80791..0000000
--- a/old/backend/internal/model/publish_queue/domain/entity/queue_item.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package entity
-
-const CollectionName = "publish_queue"
-
-const (
- StatusScheduled = "scheduled"
- StatusPublishing = "publishing"
- StatusPublished = "published"
- StatusFailed = "failed"
- StatusCancelled = "cancelled"
- StatusMissed = "missed"
-)
-
-type QueueItem struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- PersonaID string `bson:"persona_id,omitempty"`
- CopyMissionID string `bson:"copy_mission_id,omitempty"`
- CopyDraftID string `bson:"copy_draft_id,omitempty"`
- Text string `bson:"text"`
- ImageKey string `bson:"image_key,omitempty"`
- ImageKeys []string `bson:"image_keys,omitempty"`
- TopicTag string `bson:"topic_tag,omitempty"`
- ScheduledAt int64 `bson:"scheduled_at"`
- Status string `bson:"status"`
- MediaID string `bson:"media_id,omitempty"`
- Permalink string `bson:"permalink,omitempty"`
- PublishedAt int64 `bson:"published_at,omitempty"`
- ErrorMessage string `bson:"error_message,omitempty"`
- RetryCount int `bson:"retry_count,omitempty"`
- LastAttemptAt int64 `bson:"last_attempt_at,omitempty"`
- NextAttemptAt int64 `bson:"next_attempt_at,omitempty"`
- MissedAt int64 `bson:"missed_at,omitempty"`
- PausedReason string `bson:"paused_reason,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/publish_queue/domain/repository/repository.go b/old/backend/internal/model/publish_queue/domain/repository/repository.go
deleted file mode 100644
index 68c85cf..0000000
--- a/old/backend/internal/model/publish_queue/domain/repository/repository.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/publish_queue/domain/entity"
-)
-
-type ListFilter struct {
- TenantID string
- OwnerUID string
- AccountID string
- Status string
- StartAt int64
- EndAt int64
-}
-
-type ListResult struct {
- Items []entity.QueueItem
- Total int64
-}
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, item *entity.QueueItem) error
- Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error)
- ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error)
- List(ctx context.Context, filter ListFilter, page, pageSize int) (*ListResult, error)
- ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error)
- ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error)
- UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error)
- Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error)
- CountByStatus(ctx context.Context, tenantID, ownerUID, accountID string, status string) (int64, error)
- CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error)
- LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
- Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error
- ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error)
- DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error)
-}
diff --git a/old/backend/internal/model/publish_queue/domain/usecase/usecase.go b/old/backend/internal/model/publish_queue/domain/usecase/usecase.go
deleted file mode 100644
index 1927644..0000000
--- a/old/backend/internal/model/publish_queue/domain/usecase/usecase.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package usecase
-
-import "context"
-
-type QueueItemSummary struct {
- ID string
- AccountID string
- PersonaID string
- CopyMissionID string
- CopyDraftID string
- Text string
- ImageKey string
- ImageKeys []string
- TopicTag string
- ScheduledAt int64
- Status string
- MediaID string
- Permalink string
- PublishedAt int64
- ErrorMessage string
- RetryCount int
- LastAttemptAt int64
- NextAttemptAt int64
- MissedAt int64
- PausedReason string
- CreateAt int64
- UpdateAt int64
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- PersonaID string
- CopyMissionID string
- CopyDraftID string
- Text string
- ImageKey string
- ImageKeys []string
- TopicTag string
- ScheduledAt int64
-}
-
-type UpdateRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- QueueID string
- Text *string
- ImageKey *string
- ImageKeys *[]string
- TopicTag *string
- ScheduledAt *int64
-}
-
-type ListRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Status string
- Page int
- PageSize int
-}
-
-type RangeRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Status string
- StartAt int64
- EndAt int64
- Page int
- PageSize int
-}
-
-type ListResult struct {
- Items []QueueItemSummary
- Total int64
- Page int
- PageSize int
- TotalPages int
-}
-
-type PublishHealthSummary struct {
- PendingScheduled int64
- FailedCount int64
- Published7d int64
- TotalLikes7d int
- TotalReplies7d int
-}
-
-type PublishHealthCheckpoint struct {
- Checkpoint string
- LikeCount int
- ReplyCount int
- RepostCount int
- QuoteCount int
- Views int
- CheckedAt int64
-}
-
-type PublishHealthItem struct {
- MediaID string
- Permalink string
- Text string
- PublishedAt int64
- LikeCount int
- ReplyCount int
- RepostCount int
- QuoteCount int
- Views int
- Shares int
- Checkpoints []PublishHealthCheckpoint
-}
-
-type PublishHealthResult struct {
- Summary PublishHealthSummary
- Items []PublishHealthItem
- Total int64
- Page int
- PageSize int
- TotalPages int
-}
-
-type RecordPublishedRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- PersonaID string
- CopyMissionID string
- CopyDraftID string
- Text string
- ImageKey string
- ImageKeys []string
- TopicTag string
- MediaID string
- Permalink string
- PublishedAt int64
-}
-
-type SlotInsight struct {
- Weekday int
- Time string
- AvgLikes1h int
- AvgReplies1h int
- AvgLikes24h int
- AvgReplies24h int
- AvgLikes7d int
- AvgReplies7d int
- SampleSize int
- Recommendation string
-}
-
-type Alert struct {
- Type string
- Severity string
- Message string
- QueueID string
- AccountID string
- CreateAt int64
-}
-
-type UseCase interface {
- Create(ctx context.Context, req CreateRequest) (*QueueItemSummary, error)
- RecordPublished(ctx context.Context, req RecordPublishedRequest) (*QueueItemSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
- ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]QueueItemSummary, error)
- List(ctx context.Context, req ListRequest) (*ListResult, error)
- ListRange(ctx context.Context, req RangeRequest) (*ListResult, error)
- Update(ctx context.Context, req UpdateRequest) (*QueueItemSummary, error)
- Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
- Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*QueueItemSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error
- DispatchDue(ctx context.Context, limit int) (int, error)
- MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error)
- ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]Alert, error)
- ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]SlotInsight, error)
- ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*PublishHealthResult, error)
-}
diff --git a/old/backend/internal/model/publish_queue/repository/mongo.go b/old/backend/internal/model/publish_queue/repository/mongo.go
deleted file mode 100644
index ab060ff..0000000
--- a/old/backend/internal/model/publish_queue/repository/mongo.go
+++ /dev/null
@@ -1,359 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_queue/domain/entity"
- domrepo "haixun-backend/internal/model/publish_queue/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {
- Keys: bson.D{
- {Key: "tenant_id", Value: 1},
- {Key: "owner_uid", Value: 1},
- {Key: "account_id", Value: 1},
- {Key: "status", Value: 1},
- {Key: "scheduled_at", Value: 1},
- },
- },
- })
- return err
-}
-
-func actorFilter(tenantID, ownerUID, accountID string) bson.M {
- filter := bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- }
- if accountID = strings.TrimSpace(accountID); accountID != "" {
- filter["account_id"] = accountID
- }
- return filter
-}
-
-func normalizePage(page, pageSize int) (int, int) {
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 10
- }
- if pageSize > 50 {
- pageSize = 50
- }
- return page, pageSize
-}
-
-func (r *mongoRepository) Create(ctx context.Context, item *entity.QueueItem) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if item == nil {
- return app.For(code.ThreadsAccount).InputMissingRequired("queue item is required")
- }
- _, err := r.collection.InsertOne(ctx, item)
- return err
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*entity.QueueItem, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- queueID = strings.TrimSpace(queueID)
- if queueID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue id is required")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["_id"] = queueID
- var out entity.QueueItem
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("publish queue item not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]entity.QueueItem, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- trimmed := make([]string, 0, len(ids))
- for _, id := range ids {
- id = strings.TrimSpace(id)
- if id != "" {
- trimmed = append(trimmed, id)
- }
- }
- if len(trimmed) == 0 {
- return nil, nil
- }
- filter := actorFilter(tenantID, ownerUID, "")
- filter["_id"] = bson.M{"$in": trimmed}
- cur, err := r.collection.Find(ctx, filter)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.QueueItem
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoRepository) List(ctx context.Context, filter domrepo.ListFilter, page, pageSize int) (*domrepo.ListResult, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- page, pageSize = normalizePage(page, pageSize)
- mongoFilter := actorFilter(filter.TenantID, filter.OwnerUID, filter.AccountID)
- if status := strings.TrimSpace(filter.Status); status != "" {
- mongoFilter["status"] = status
- }
- if filter.StartAt > 0 || filter.EndAt > 0 {
- rangeFilter := bson.M{}
- if filter.StartAt > 0 {
- rangeFilter["$gte"] = filter.StartAt
- }
- if filter.EndAt > 0 {
- rangeFilter["$lte"] = filter.EndAt
- }
- mongoFilter["scheduled_at"] = rangeFilter
- }
- total, err := r.collection.CountDocuments(ctx, mongoFilter)
- if err != nil {
- return nil, err
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "scheduled_at", Value: -1}}).
- SetSkip(int64((page - 1) * pageSize)).
- SetLimit(int64(pageSize))
- cur, err := r.collection.Find(ctx, mongoFilter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var items []entity.QueueItem
- if err := cur.All(ctx, &items); err != nil {
- return nil, err
- }
- return &domrepo.ListResult{Items: items, Total: total}, nil
-}
-
-func (r *mongoRepository) ListDue(ctx context.Context, now int64, limit int) ([]entity.QueueItem, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 20
- }
- filter := bson.M{
- "status": entity.StatusScheduled,
- "scheduled_at": bson.M{"$lte": now},
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).
- SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var items []entity.QueueItem
- if err := cur.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) ListMissedCandidates(ctx context.Context, before int64, limit int) ([]entity.QueueItem, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 20
- }
- filter := bson.M{
- "status": entity.StatusScheduled,
- "scheduled_at": bson.M{"$lte": before},
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "scheduled_at", Value: 1}}).
- SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var items []entity.QueueItem
- if err := cur.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) UpdateIfStatus(ctx context.Context, item *entity.QueueItem, allowed []string) (*entity.QueueItem, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if item == nil || item.ID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue item is required")
- }
- filter := bson.M{
- "_id": item.ID,
- "status": bson.M{"$in": allowed},
- }
- res, err := r.collection.ReplaceOne(ctx, filter, item)
- if err != nil {
- return nil, err
- }
- if res.MatchedCount == 0 {
- return nil, app.For(code.ThreadsAccount).ResInvalidState("queue item status changed; update rejected")
- }
- return item, nil
-}
-
-func (r *mongoRepository) Update(ctx context.Context, item *entity.QueueItem) (*entity.QueueItem, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if item == nil || item.ID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("queue item is required")
- }
- filter := bson.M{"_id": item.ID}
- res, err := r.collection.ReplaceOne(ctx, filter, item)
- if err != nil {
- return nil, err
- }
- if res.MatchedCount == 0 {
- return nil, app.For(code.ThreadsAccount).ResNotFound("publish queue item not found")
- }
- return item, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- queueID = strings.TrimSpace(queueID)
- if queueID == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("queue id is required")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["_id"] = queueID
- res, err := r.collection.DeleteOne(ctx, filter)
- if err != nil {
- return err
- }
- if res.DeletedCount == 0 {
- return app.For(code.ThreadsAccount).ResNotFound("publish queue item not found")
- }
- return nil
-}
-
-func (r *mongoRepository) CountByStatus(ctx context.Context, tenantID, ownerUID, accountID, status string) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["status"] = strings.TrimSpace(status)
- return r.collection.CountDocuments(ctx, filter)
-}
-
-func (r *mongoRepository) CountPublishedSince(ctx context.Context, tenantID, ownerUID, accountID string, since int64) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["status"] = entity.StatusPublished
- filter["published_at"] = bson.M{"$gte": since}
- return r.collection.CountDocuments(ctx, filter)
-}
-
-func (r *mongoRepository) LastPublishedAt(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["status"] = entity.StatusPublished
- opts := options.FindOne().SetSort(bson.D{{Key: "published_at", Value: -1}})
- var out entity.QueueItem
- err := r.collection.FindOne(ctx, filter, opts).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return 0, nil
- }
- return 0, err
- }
- return out.PublishedAt, nil
-}
-
-func (r *mongoRepository) ListMediaIDsByAccount(ctx context.Context, tenantID, ownerUID, accountID string) ([]string, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["media_id"] = bson.M{"$ne": ""}
- cur, err := r.collection.Find(ctx, filter, options.Find().SetProjection(bson.M{"media_id": 1}))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- seen := map[string]struct{}{}
- var ids []string
- for cur.Next(ctx) {
- var row struct {
- MediaID string `bson:"media_id"`
- }
- if err := cur.Decode(&row); err != nil {
- return nil, err
- }
- if id := strings.TrimSpace(row.MediaID); id != "" {
- if _, ok := seen[id]; !ok {
- seen[id] = struct{}{}
- ids = append(ids, id)
- }
- }
- }
- if err := cur.Err(); err != nil {
- return nil, err
- }
- return ids, nil
-}
-
-func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) (int64, error) {
- if r.collection == nil {
- return 0, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- res, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID))
- if err != nil {
- return 0, err
- }
- return res.DeletedCount, nil
-}
diff --git a/old/backend/internal/model/publish_queue/usecase/usecase.go b/old/backend/internal/model/publish_queue/usecase/usecase.go
deleted file mode 100644
index 387235e..0000000
--- a/old/backend/internal/model/publish_queue/usecase/usecase.go
+++ /dev/null
@@ -1,881 +0,0 @@
-package usecase
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/storage"
- "haixun-backend/internal/library/threadspost"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- padomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
- guarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
- "haixun-backend/internal/model/publish_queue/domain/entity"
- domrepo "haixun-backend/internal/model/publish_queue/domain/repository"
- domusecase "haixun-backend/internal/model/publish_queue/domain/usecase"
- evententity "haixun-backend/internal/model/publish_queue_event/domain/entity"
- eventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type publishQueueUseCase struct {
- repo domrepo.Repository
- threadsAccount threadsaccountdomain.UseCase
- publishAnalytics padomain.UseCase
- guard guarddomain.UseCase
- events eventdomain.UseCase
- copyDraft copydraftdomain.UseCase
- attachmentStore storage.PublishAttachmentStore
-}
-
-func NewUseCase(
- repo domrepo.Repository,
- threadsAccount threadsaccountdomain.UseCase,
- publishAnalytics padomain.UseCase,
- guard guarddomain.UseCase,
- events eventdomain.UseCase,
- copyDraft copydraftdomain.UseCase,
- attachmentStore storage.PublishAttachmentStore,
-) domusecase.UseCase {
- return &publishQueueUseCase{
- repo: repo,
- threadsAccount: threadsAccount,
- publishAnalytics: publishAnalytics,
- guard: guard,
- events: events,
- copyDraft: copyDraft,
- attachmentStore: attachmentStore,
- }
-}
-
-func (u *publishQueueUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.QueueItemSummary, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- ownerUID := strings.TrimSpace(req.OwnerUID)
- accountID := strings.TrimSpace(req.AccountID)
- text := strings.TrimSpace(req.Text)
- imageKeys := storage.NormalizeImageKeys(req.ImageKey, req.ImageKeys)
- if tenantID == "" || ownerUID == "" {
- return nil, app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if accountID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- if text == "" && len(imageKeys) == 0 {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("text or image_keys is required")
- }
- for _, imageKey := range imageKeys {
- if err := storage.ValidatePublishAttachmentKey(imageKey); err != nil {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- }
- if err := threadspost.ValidatePublishContent(text, len(imageKeys) > 0); err != nil {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil {
- return nil, err
- }
-
- now := clock.NowUnixNano()
- scheduledAt := req.ScheduledAt
- if scheduledAt <= 0 {
- scheduledAt = now
- }
-
- item := &entity.QueueItem{
- ID: uuid.NewString(),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- AccountID: accountID,
- PersonaID: strings.TrimSpace(req.PersonaID),
- CopyMissionID: strings.TrimSpace(req.CopyMissionID),
- CopyDraftID: strings.TrimSpace(req.CopyDraftID),
- Text: text,
- ImageKeys: imageKeys,
- TopicTag: normalizeTopicTag(req.TopicTag),
- ScheduledAt: scheduledAt,
- Status: entity.StatusScheduled,
- CreateAt: now,
- UpdateAt: now,
- }
- if err := u.repo.Create(ctx, item); err != nil {
- return nil, err
- }
- u.recordEvent(ctx, item, evententity.EventCreated, "", item.Status, "加入發文庫存")
-
- if scheduledAt <= now {
- _, _ = u.DispatchDue(ctx, 1)
- fresh, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, item.ID)
- if err == nil && fresh != nil {
- out := toSummary(*fresh)
- return &out, nil
- }
- }
-
- out := toSummary(*item)
- return &out, nil
-}
-
-func (u *publishQueueUseCase) RecordPublished(ctx context.Context, req domusecase.RecordPublishedRequest) (*domusecase.QueueItemSummary, error) {
- tenantID := strings.TrimSpace(req.TenantID)
- ownerUID := strings.TrimSpace(req.OwnerUID)
- accountID := strings.TrimSpace(req.AccountID)
- mediaID := strings.TrimSpace(req.MediaID)
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if accountID == "" || mediaID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id and media_id are required")
- }
- if _, err := u.threadsAccount.Get(ctx, tenantID, ownerUID, accountID); err != nil {
- return nil, err
- }
- now := clock.NowUnixNano()
- publishedAt := req.PublishedAt
- if publishedAt <= 0 {
- publishedAt = now
- }
- item := &entity.QueueItem{
- ID: uuid.NewString(),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- AccountID: accountID,
- PersonaID: strings.TrimSpace(req.PersonaID),
- CopyMissionID: strings.TrimSpace(req.CopyMissionID),
- CopyDraftID: strings.TrimSpace(req.CopyDraftID),
- Text: strings.TrimSpace(req.Text),
- TopicTag: normalizeTopicTag(req.TopicTag),
- ScheduledAt: publishedAt,
- Status: entity.StatusPublished,
- MediaID: mediaID,
- Permalink: strings.TrimSpace(req.Permalink),
- PublishedAt: publishedAt,
- CreateAt: now,
- UpdateAt: now,
- }
- if err := u.repo.Create(ctx, item); err != nil {
- return nil, err
- }
- u.recordEvent(ctx, item, evententity.EventPublished, "", item.Status, "已記錄發布結果")
- _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
- TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID,
- MediaID: mediaID, Permalink: item.Permalink, PublishedAt: publishedAt,
- })
- out := toSummary(*item)
- return &out, nil
-}
-
-func (u *publishQueueUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
- if err != nil {
- return nil, err
- }
- out := toSummary(*item)
- return &out, nil
-}
-
-func (u *publishQueueUseCase) ListByIDs(ctx context.Context, tenantID, ownerUID string, ids []string) ([]domusecase.QueueItemSummary, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByIDs(ctx, tenantID, ownerUID, ids)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.QueueItemSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func (u *publishQueueUseCase) List(ctx context.Context, req domusecase.ListRequest) (*domusecase.ListResult, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- page, pageSize := req.Page, req.PageSize
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 10
- }
- if pageSize > 50 {
- pageSize = 50
- }
- result, err := u.repo.List(ctx, domrepo.ListFilter{
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- AccountID: req.AccountID,
- Status: req.Status,
- }, page, pageSize)
- if err != nil {
- return nil, err
- }
- items := make([]domusecase.QueueItemSummary, 0, len(result.Items))
- for _, item := range result.Items {
- items = append(items, toSummary(item))
- }
- totalPages := 0
- if pageSize > 0 {
- totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
- }
- return &domusecase.ListResult{
- Items: items,
- Total: result.Total,
- Page: page,
- PageSize: pageSize,
- TotalPages: totalPages,
- }, nil
-}
-
-func (u *publishQueueUseCase) ListRange(ctx context.Context, req domusecase.RangeRequest) (*domusecase.ListResult, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- page, pageSize := req.Page, req.PageSize
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 50
- }
- if pageSize > 200 {
- pageSize = 200
- }
- result, err := u.repo.List(ctx, domrepo.ListFilter{
- TenantID: req.TenantID, OwnerUID: req.OwnerUID, AccountID: req.AccountID,
- Status: req.Status, StartAt: req.StartAt, EndAt: req.EndAt,
- }, page, pageSize)
- if err != nil {
- return nil, err
- }
- items := make([]domusecase.QueueItemSummary, 0, len(result.Items))
- for _, item := range result.Items {
- items = append(items, toSummary(item))
- }
- totalPages := 0
- if pageSize > 0 {
- totalPages = int((result.Total + int64(pageSize) - 1) / int64(pageSize))
- }
- return &domusecase.ListResult{Items: items, Total: result.Total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil
-}
-
-func (u *publishQueueUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.QueueItemSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.QueueID)
- if err != nil {
- return nil, err
- }
- if item.Status != entity.StatusScheduled {
- return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可修改")
- }
- if req.Text != nil {
- item.Text = strings.TrimSpace(*req.Text)
- }
- if req.ImageKeys != nil || req.ImageKey != nil {
- single := ""
- list := queueItemImageKeys(*item)
- if req.ImageKey != nil {
- single = *req.ImageKey
- }
- if req.ImageKeys != nil {
- list = *req.ImageKeys
- single = ""
- }
- newKeys := storage.NormalizeImageKeys(single, list)
- for _, imageKey := range newKeys {
- if err := storage.ValidatePublishAttachmentKey(imageKey); err != nil {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- }
- u.purgeRemovedAttachments(ctx, queueItemImageKeys(*item), newKeys)
- item.ImageKeys = newKeys
- item.ImageKey = ""
- }
- if strings.TrimSpace(item.Text) == "" && len(queueItemImageKeys(*item)) == 0 {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("text or image_keys is required")
- }
- if err := threadspost.ValidatePublishContent(item.Text, len(queueItemImageKeys(*item)) > 0); err != nil {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat(err.Error())
- }
- if req.ScheduledAt != nil && *req.ScheduledAt > 0 {
- item.ScheduledAt = *req.ScheduledAt
- }
- if req.TopicTag != nil {
- item.TopicTag = normalizeTopicTag(*req.TopicTag)
- }
- item.UpdateAt = clock.NowUnixNano()
- updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
- if err != nil {
- return nil, err
- }
- out := toSummary(*updated)
- return &out, nil
-}
-
-func (u *publishQueueUseCase) Cancel(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
- if err != nil {
- return nil, err
- }
- if item.Status != entity.StatusScheduled {
- return nil, app.For(code.ThreadsAccount).ResInvalidState("僅排程中的項目可取消")
- }
- item.Status = entity.StatusCancelled
- item.UpdateAt = clock.NowUnixNano()
- updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
- if err != nil {
- return nil, err
- }
- u.recordEvent(ctx, updated, evententity.EventCancelled, entity.StatusScheduled, updated.Status, "已取消排程")
- out := toSummary(*updated)
- return &out, nil
-}
-
-func (u *publishQueueUseCase) Retry(ctx context.Context, tenantID, ownerUID, accountID, queueID string) (*domusecase.QueueItemSummary, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
- if err != nil {
- return nil, err
- }
- if item.Status != entity.StatusFailed && item.Status != entity.StatusCancelled && item.Status != entity.StatusMissed {
- return nil, app.For(code.ThreadsAccount).ResInvalidState("僅 failed/cancelled/missed 項目可重試")
- }
- from := item.Status
- item.Status = entity.StatusScheduled
- item.ErrorMessage = ""
- item.PausedReason = ""
- item.MissedAt = 0
- item.RetryCount++
- item.NextAttemptAt = 0
- now := clock.NowUnixNano()
- item.UpdateAt = now
- if item.ScheduledAt < now {
- item.ScheduledAt = now
- }
- updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusFailed, entity.StatusCancelled, entity.StatusMissed})
- if err != nil {
- return nil, err
- }
- u.recordEvent(ctx, updated, evententity.EventRetry, from, updated.Status, "已重新排程")
- out := toSummary(*updated)
- return &out, nil
-}
-
-func (u *publishQueueUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID, queueID string) error {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return err
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, accountID, queueID)
- if err != nil {
- return err
- }
- switch item.Status {
- case entity.StatusPublishing:
- return app.For(code.ThreadsAccount).ResInvalidState("發佈進行中,無法刪除")
- case entity.StatusPublished:
- return app.For(code.ThreadsAccount).ResInvalidState("已發佈項目不可刪除")
- case entity.StatusScheduled:
- _, err = u.Cancel(ctx, tenantID, ownerUID, accountID, queueID)
- return err
- default:
- u.purgePublishAttachments(ctx, queueItemImageKeys(*item))
- return u.repo.Delete(ctx, tenantID, ownerUID, accountID, queueID)
- }
-}
-
-func (u *publishQueueUseCase) DispatchDue(ctx context.Context, limit int) (int, error) {
- if u.repo == nil {
- return 0, nil
- }
- now := clock.NowUnixNano()
- due, err := u.repo.ListDue(ctx, now, limit)
- if err != nil {
- return 0, err
- }
- dispatched := 0
- for _, item := range due {
- if err := u.dispatchOne(ctx, &item); err != nil {
- continue
- }
- dispatched++
- }
- return dispatched, nil
-}
-
-func (u *publishQueueUseCase) dispatchOne(ctx context.Context, item *entity.QueueItem) error {
- if result, err := u.checkGuard(ctx, item); err == nil && result != nil && !result.Allowed {
- if result.ShouldPause && u.guard != nil {
- _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, result.Reason)
- }
- item.PausedReason = result.Reason
- if result.DelayUntil > 0 {
- item.ScheduledAt = result.DelayUntil
- } else {
- item.Status = entity.StatusFailed
- item.ErrorMessage = result.Reason
- }
- item.UpdateAt = clock.NowUnixNano()
- updated, updateErr := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
- if updateErr != nil {
- return updateErr
- }
- u.recordEvent(ctx, updated, evententity.EventGuardDelayed, entity.StatusScheduled, updated.Status, result.Reason)
- return nil
- }
- item.Status = entity.StatusPublishing
- item.LastAttemptAt = clock.NowUnixNano()
- item.UpdateAt = clock.NowUnixNano()
- claimed, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusScheduled})
- if err != nil {
- return err
- }
- u.recordEvent(ctx, claimed, evententity.EventPublishing, entity.StatusScheduled, entity.StatusPublishing, "開始發布")
-
- account, err := u.threadsAccount.Get(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
- if err != nil {
- return u.markFailed(ctx, claimed, "取得帳號失敗:"+err.Error())
- }
- token, err := u.threadsAccount.ResolveAccountAPIAccessToken(ctx, claimed.TenantID, claimed.OwnerUID, claimed.AccountID)
- if err != nil {
- return u.markFailed(ctx, claimed, err.Error())
- }
- userID := strings.TrimSpace(account.ThreadsUserID)
- if userID == "" || token == "" {
- return u.markFailed(ctx, claimed, "Threads API 未連線")
- }
-
- imageURLs, err := u.resolveImageURLs(queueItemImageKeys(*claimed))
- if err != nil {
- return u.markFailed(ctx, claimed, err.Error())
- }
- result, err := libthreads.PublishText(ctx, libthreads.PublishTextInput{
- ThreadsUserID: userID,
- AccessToken: token,
- Text: claimed.Text,
- TopicTag: claimed.TopicTag,
- ImageURLs: imageURLs,
- })
- if err != nil {
- return u.markFailed(ctx, claimed, "發文失敗:"+err.Error())
- }
-
- now := clock.NowUnixNano()
- attachmentKeys := queueItemImageKeys(*claimed)
- claimed.Status = entity.StatusPublished
- claimed.MediaID = result.MediaID
- claimed.Permalink = result.Permalink
- claimed.PublishedAt = now
- claimed.UpdateAt = now
- claimed.ErrorMessage = ""
- claimed.ImageKey = ""
- claimed.ImageKeys = nil
- if _, err := u.repo.UpdateIfStatus(ctx, claimed, []string{entity.StatusPublishing}); err != nil {
- return err
- }
- u.purgePublishAttachments(ctx, attachmentKeys)
- u.recordEvent(ctx, claimed, evententity.EventPublished, entity.StatusPublishing, entity.StatusPublished, "Threads API 發布完成")
- if claimed.CopyDraftID != "" && u.copyDraft != nil {
- _, _ = u.copyDraft.MarkPublished(ctx, copydraftdomain.MarkPublishedRequest{
- TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, PersonaID: claimed.PersonaID,
- DraftID: claimed.CopyDraftID, MediaID: result.MediaID, Permalink: result.Permalink,
- })
- }
- _ = u.publishAnalytics.ScheduleCheckpoints(ctx, padomain.ScheduleCheckpointsRequest{
- TenantID: claimed.TenantID, OwnerUID: claimed.OwnerUID, AccountID: claimed.AccountID,
- MediaID: result.MediaID, Permalink: result.Permalink, PublishedAt: now,
- })
- return nil
-}
-
-func (u *publishQueueUseCase) markFailed(ctx context.Context, item *entity.QueueItem, message string) error {
- item.Status = entity.StatusFailed
- item.ErrorMessage = message
- item.LastAttemptAt = clock.NowUnixNano()
- item.UpdateAt = clock.NowUnixNano()
- updated, err := u.repo.UpdateIfStatus(ctx, item, []string{entity.StatusPublishing, entity.StatusScheduled})
- if err == nil {
- u.recordEvent(ctx, updated, evententity.EventFailed, entity.StatusPublishing, entity.StatusFailed, message)
- failureStreak := u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID)
- if u.guard != nil && failureStreak >= 3 {
- _, _ = u.guard.Pause(ctx, item.TenantID, item.OwnerUID, item.AccountID, "連續發文失敗,已暫停帳號")
- }
- }
- return err
-}
-
-func (u *publishQueueUseCase) ListPublishHealth(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*domusecase.PublishHealthResult, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = 10
- }
- if pageSize > 30 {
- pageSize = 30
- }
-
- pending, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusScheduled)
- failed, _ := u.repo.CountByStatus(ctx, tenantID, ownerUID, accountID, entity.StatusFailed)
-
- publishedList, err := u.repo.List(ctx, domrepo.ListFilter{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- AccountID: accountID,
- Status: entity.StatusPublished,
- }, page, pageSize)
- if err != nil {
- return nil, err
- }
-
- cutoff7d := clock.NowUnixNano() - int64(7*24*time.Hour)
- var published7d int64
- var totalLikes7d, totalReplies7d int
-
- items := make([]domusecase.PublishHealthItem, 0, len(publishedList.Items))
- for _, row := range publishedList.Items {
- if row.PublishedAt >= cutoff7d {
- published7d++
- }
- checkpoints, err := u.publishAnalytics.ListByMedia(ctx, tenantID, ownerUID, row.MediaID)
- if err != nil {
- checkpoints = nil
- }
- healthCPs := make([]domusecase.PublishHealthCheckpoint, 0, len(checkpoints))
- var latestLike, latestReply, latestRepost, latestQuote, latestViews, latestShares int
- var latestChecked int64
- for _, cp := range checkpoints {
- healthCPs = append(healthCPs, domusecase.PublishHealthCheckpoint{
- Checkpoint: cp.Checkpoint,
- LikeCount: cp.LikeCount,
- ReplyCount: cp.ReplyCount,
- RepostCount: cp.RepostCount,
- QuoteCount: cp.QuoteCount,
- Views: cp.ViewCount,
- CheckedAt: cp.CheckedAt,
- })
- if cp.CheckedAt >= latestChecked {
- latestChecked = cp.CheckedAt
- latestLike = cp.LikeCount
- latestReply = cp.ReplyCount
- latestRepost = cp.RepostCount
- latestQuote = cp.QuoteCount
- latestViews = cp.ViewCount
- latestShares = cp.ShareCount
- }
- }
- if row.PublishedAt >= cutoff7d {
- totalLikes7d += latestLike
- totalReplies7d += latestReply
- }
- items = append(items, domusecase.PublishHealthItem{
- MediaID: row.MediaID,
- Permalink: row.Permalink,
- Text: row.Text,
- PublishedAt: row.PublishedAt,
- LikeCount: latestLike,
- ReplyCount: latestReply,
- RepostCount: latestRepost,
- QuoteCount: latestQuote,
- Views: latestViews,
- Shares: latestShares,
- Checkpoints: healthCPs,
- })
- }
-
- totalPages := 0
- if pageSize > 0 {
- totalPages = int((publishedList.Total + int64(pageSize) - 1) / int64(pageSize))
- }
- return &domusecase.PublishHealthResult{
- Summary: domusecase.PublishHealthSummary{
- PendingScheduled: pending,
- FailedCount: failed,
- Published7d: published7d,
- TotalLikes7d: totalLikes7d,
- TotalReplies7d: totalReplies7d,
- },
- Items: items,
- Total: publishedList.Total,
- Page: page,
- PageSize: pageSize,
- TotalPages: totalPages,
- }, nil
-}
-
-func (u *publishQueueUseCase) MarkMissed(ctx context.Context, graceNs int64, limit int) (int, error) {
- if graceNs <= 0 {
- graceNs = int64(10 * time.Minute)
- }
- before := clock.NowUnixNano() - graceNs
- items, err := u.repo.ListMissedCandidates(ctx, before, limit)
- if err != nil {
- return 0, err
- }
- count := 0
- for _, item := range items {
- item.Status = entity.StatusMissed
- item.MissedAt = clock.NowUnixNano()
- item.UpdateAt = item.MissedAt
- updated, err := u.repo.UpdateIfStatus(ctx, &item, []string{entity.StatusScheduled})
- if err != nil {
- continue
- }
- u.recordEvent(ctx, updated, evententity.EventMissed, entity.StatusScheduled, entity.StatusMissed, "超過 grace period 未發布")
- count++
- }
- return count, nil
-}
-
-func (u *publishQueueUseCase) ListAlerts(ctx context.Context, tenantID, ownerUID, accountID string) ([]domusecase.Alert, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 50)
- if err != nil {
- return nil, err
- }
- alerts := make([]domusecase.Alert, 0)
- for _, item := range result.Items {
- switch item.Status {
- case entity.StatusFailed:
- alerts = append(alerts, domusecase.Alert{Type: "failed", Severity: "danger", Message: firstNonEmpty(item.ErrorMessage, "發文失敗"), QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
- case entity.StatusMissed:
- alerts = append(alerts, domusecase.Alert{Type: "missed", Severity: "warning", Message: "排程已漏發", QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.MissedAt})
- }
- if item.PausedReason != "" {
- alerts = append(alerts, domusecase.Alert{Type: "guard", Severity: "warning", Message: item.PausedReason, QueueID: item.ID, AccountID: item.AccountID, CreateAt: item.UpdateAt})
- }
- }
- return alerts, nil
-}
-
-func (u *publishQueueUseCase) ListSlotInsights(ctx context.Context, tenantID, ownerUID, accountID, timezone string) ([]domusecase.SlotInsight, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- health, err := u.ListPublishHealth(ctx, tenantID, ownerUID, accountID, 1, 30)
- if err != nil {
- return nil, err
- }
- type agg struct {
- likes7d int
- replies7d int
- samples int
- }
- slots := map[string]*agg{}
- for _, item := range health.Items {
- t := time.Unix(0, item.PublishedAt).UTC()
- key := t.Format("15:04")
- if _, ok := slots[key]; !ok {
- slots[key] = &agg{}
- }
- slots[key].likes7d += item.LikeCount
- slots[key].replies7d += item.ReplyCount
- slots[key].samples++
- }
- out := make([]domusecase.SlotInsight, 0, len(slots))
- for key, item := range slots {
- avgLikes := 0
- avgReplies := 0
- if item.samples > 0 {
- avgLikes = item.likes7d / item.samples
- avgReplies = item.replies7d / item.samples
- }
- recommendation := "neutral"
- if avgLikes+avgReplies >= 5 {
- recommendation = "recommended"
- } else if item.samples >= 2 {
- recommendation = "low"
- }
- out = append(out, domusecase.SlotInsight{
- Weekday: -1, Time: key, AvgLikes1h: avgLikes, AvgReplies1h: avgReplies,
- AvgLikes24h: avgLikes, AvgReplies24h: avgReplies, AvgLikes7d: avgLikes, AvgReplies7d: avgReplies,
- SampleSize: item.samples, Recommendation: recommendation,
- })
- }
- if len(out) == 0 {
- out = []domusecase.SlotInsight{
- {Weekday: 1, Time: "09:30", Recommendation: "recommended"},
- {Weekday: 3, Time: "12:30", Recommendation: "neutral"},
- {Weekday: 5, Time: "18:30", Recommendation: "recommended"},
- }
- }
- return out, nil
-}
-
-func (u *publishQueueUseCase) checkGuard(ctx context.Context, item *entity.QueueItem) (*guarddomain.CheckResult, error) {
- if u.guard == nil || item == nil {
- return &guarddomain.CheckResult{Allowed: true}, nil
- }
- now := clock.NowUnixNano()
- publishedToday, _ := u.repo.CountPublishedSince(ctx, item.TenantID, item.OwnerUID, item.AccountID, startOfUTCDay(now))
- lastPublishedAt, _ := u.repo.LastPublishedAt(ctx, item.TenantID, item.OwnerUID, item.AccountID)
- return u.guard.Check(ctx, guarddomain.CheckRequest{
- TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID,
- Now: now, PublishedToday: publishedToday, LastPublishedAt: lastPublishedAt, FailureStreak: u.failureStreak(ctx, item.TenantID, item.OwnerUID, item.AccountID),
- })
-}
-
-func (u *publishQueueUseCase) failureStreak(ctx context.Context, tenantID, ownerUID, accountID string) int {
- result, err := u.repo.List(ctx, domrepo.ListFilter{TenantID: tenantID, OwnerUID: ownerUID, AccountID: accountID}, 1, 10)
- if err != nil {
- return 0
- }
- streak := 0
- for _, item := range result.Items {
- if item.Status == entity.StatusFailed {
- streak++
- continue
- }
- if item.Status == entity.StatusPublished {
- break
- }
- }
- return streak
-}
-
-func (u *publishQueueUseCase) recordEvent(ctx context.Context, item *entity.QueueItem, eventType, fromStatus, toStatus, message string) {
- if u.events == nil || item == nil {
- return
- }
- _ = u.events.Record(ctx, eventdomain.RecordRequest{
- TenantID: item.TenantID, OwnerUID: item.OwnerUID, AccountID: item.AccountID, QueueID: item.ID,
- EventType: eventType, FromStatus: fromStatus, ToStatus: toStatus, Message: message,
- })
-}
-
-func startOfUTCDay(ns int64) int64 {
- t := time.Unix(0, ns).UTC()
- return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixNano()
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
-}
-
-func firstImageKey(item entity.QueueItem) string {
- keys := queueItemImageKeys(item)
- if len(keys) == 0 {
- return ""
- }
- return keys[0]
-}
-
-func requireActor(tenantID, ownerUID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- return nil
-}
-
-func queueItemImageKeys(item entity.QueueItem) []string {
- if len(item.ImageKeys) > 0 {
- return item.ImageKeys
- }
- if key := strings.TrimSpace(item.ImageKey); key != "" {
- return []string{key}
- }
- return nil
-}
-
-func (u *publishQueueUseCase) resolveImageURLs(imageKeys []string) ([]string, error) {
- imageKeys = storage.NormalizeImageKeys("", imageKeys)
- if len(imageKeys) == 0 {
- return nil, nil
- }
- if u.attachmentStore == nil {
- return nil, fmt.Errorf("圖片儲存未設定,無法發布附圖貼文")
- }
- urls := make([]string, 0, len(imageKeys))
- for _, imageKey := range imageKeys {
- url, err := u.attachmentStore.ResolvePublicURL(imageKey)
- if err != nil {
- return nil, fmt.Errorf("圖片網址無法解析:%w", err)
- }
- urls = append(urls, url)
- }
- return urls, nil
-}
-
-func (u *publishQueueUseCase) purgePublishAttachments(ctx context.Context, imageKeys []string) {
- storage.PurgePublishAttachments(ctx, u.attachmentStore, imageKeys)
-}
-
-func (u *publishQueueUseCase) purgeRemovedAttachments(ctx context.Context, oldKeys, newKeys []string) {
- oldSet := make(map[string]struct{}, len(oldKeys))
- for _, key := range oldKeys {
- oldSet[key] = struct{}{}
- }
- for _, key := range newKeys {
- delete(oldSet, key)
- }
- removed := make([]string, 0, len(oldSet))
- for key := range oldSet {
- removed = append(removed, key)
- }
- u.purgePublishAttachments(ctx, removed)
-}
-
-func toSummary(item entity.QueueItem) domusecase.QueueItemSummary {
- return domusecase.QueueItemSummary{
- ID: item.ID,
- AccountID: item.AccountID,
- PersonaID: item.PersonaID,
- CopyMissionID: item.CopyMissionID,
- CopyDraftID: item.CopyDraftID,
- Text: item.Text,
- ImageKey: firstImageKey(item),
- ImageKeys: queueItemImageKeys(item),
- TopicTag: item.TopicTag,
- ScheduledAt: item.ScheduledAt,
- Status: item.Status,
- MediaID: item.MediaID,
- Permalink: item.Permalink,
- PublishedAt: item.PublishedAt,
- ErrorMessage: item.ErrorMessage,
- RetryCount: item.RetryCount,
- LastAttemptAt: item.LastAttemptAt,
- NextAttemptAt: item.NextAttemptAt,
- MissedAt: item.MissedAt,
- PausedReason: item.PausedReason,
- CreateAt: item.CreateAt,
- UpdateAt: item.UpdateAt,
- }
-}
-
-func normalizeTopicTag(value string) string {
- value = strings.TrimSpace(value)
- value = strings.TrimPrefix(value, "#")
- value = strings.TrimSpace(value)
- if len([]rune(value)) > 40 {
- runes := []rune(value)
- value = string(runes[:40])
- }
- return value
-}
diff --git a/old/backend/internal/model/publish_queue_event/domain/entity/event.go b/old/backend/internal/model/publish_queue_event/domain/entity/event.go
deleted file mode 100644
index 9a8c938..0000000
--- a/old/backend/internal/model/publish_queue_event/domain/entity/event.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package entity
-
-const CollectionName = "publish_queue_events"
-
-const (
- EventCreated = "created"
- EventRetry = "retry"
- EventPublishing = "publishing"
- EventPublished = "published"
- EventFailed = "failed"
- EventCancelled = "cancelled"
- EventMissed = "missed"
- EventGuardDelayed = "guard_delayed"
-)
-
-type Event struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- AccountID string `bson:"account_id"`
- QueueID string `bson:"queue_id"`
- EventType string `bson:"event_type"`
- FromStatus string `bson:"from_status,omitempty"`
- ToStatus string `bson:"to_status,omitempty"`
- Message string `bson:"message,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
diff --git a/old/backend/internal/model/publish_queue_event/domain/repository/repository.go b/old/backend/internal/model/publish_queue_event/domain/repository/repository.go
deleted file mode 100644
index ec04c8d..0000000
--- a/old/backend/internal/model/publish_queue_event/domain/repository/repository.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/publish_queue_event/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, event *entity.Event) error
- ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error)
- ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error)
- DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error
-}
diff --git a/old/backend/internal/model/publish_queue_event/domain/usecase/usecase.go b/old/backend/internal/model/publish_queue_event/domain/usecase/usecase.go
deleted file mode 100644
index 704e271..0000000
--- a/old/backend/internal/model/publish_queue_event/domain/usecase/usecase.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package usecase
-
-import "context"
-
-type EventSummary struct {
- ID string
- QueueID string
- AccountID string
- EventType string
- FromStatus string
- ToStatus string
- Message string
- CreateAt int64
-}
-
-type RecordRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- QueueID string
- EventType string
- FromStatus string
- ToStatus string
- Message string
-}
-
-type UseCase interface {
- Record(ctx context.Context, req RecordRequest) error
- ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]EventSummary, error)
- ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]EventSummary, error)
-}
diff --git a/old/backend/internal/model/publish_queue_event/repository/mongo.go b/old/backend/internal/model/publish_queue_event/repository/mongo.go
deleted file mode 100644
index 310513a..0000000
--- a/old/backend/internal/model/publish_queue_event/repository/mongo.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_queue_event/domain/entity"
- domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}, {Key: "queue_id", Value: 1}, {Key: "create_at", Value: -1}}},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, event *entity.Event) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if event == nil {
- return app.For(code.ThreadsAccount).InputMissingRequired("event is required")
- }
- _, err := r.collection.InsertOne(ctx, event)
- return err
-}
-
-func (r *mongoRepository) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]entity.Event, error) {
- filter := actorFilter(tenantID, ownerUID, accountID)
- filter["queue_id"] = strings.TrimSpace(queueID)
- return r.list(ctx, filter, limit)
-}
-
-func (r *mongoRepository) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]entity.Event, error) {
- return r.list(ctx, actorFilter(tenantID, ownerUID, accountID), limit)
-}
-
-func (r *mongoRepository) DeleteByAccount(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteMany(ctx, actorFilter(tenantID, ownerUID, accountID))
- return err
-}
-
-func (r *mongoRepository) list(ctx context.Context, filter bson.M, limit int) ([]entity.Event, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if limit <= 0 {
- limit = 50
- }
- if limit > 200 {
- limit = 200
- }
- opts := options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, filter, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.Event
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func actorFilter(tenantID, ownerUID, accountID string) bson.M {
- filter := bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- }
- if accountID = strings.TrimSpace(accountID); accountID != "" {
- filter["account_id"] = accountID
- }
- return filter
-}
diff --git a/old/backend/internal/model/publish_queue_event/usecase/usecase.go b/old/backend/internal/model/publish_queue_event/usecase/usecase.go
deleted file mode 100644
index b109522..0000000
--- a/old/backend/internal/model/publish_queue_event/usecase/usecase.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/publish_queue_event/domain/entity"
- domrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
- domusecase "haixun-backend/internal/model/publish_queue_event/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type eventUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &eventUseCase{repo: repo}
-}
-
-func (u *eventUseCase) Record(ctx context.Context, req domusecase.RecordRequest) error {
- if strings.TrimSpace(req.TenantID) == "" || strings.TrimSpace(req.OwnerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(req.AccountID) == "" || strings.TrimSpace(req.QueueID) == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("account_id and queue_id are required")
- }
- eventType := strings.TrimSpace(req.EventType)
- if eventType == "" {
- eventType = entity.EventCreated
- }
- return u.repo.Create(ctx, &entity.Event{
- ID: uuid.NewString(),
- TenantID: strings.TrimSpace(req.TenantID),
- OwnerUID: strings.TrimSpace(req.OwnerUID),
- AccountID: strings.TrimSpace(req.AccountID),
- QueueID: strings.TrimSpace(req.QueueID),
- EventType: eventType,
- FromStatus: strings.TrimSpace(req.FromStatus),
- ToStatus: strings.TrimSpace(req.ToStatus),
- Message: strings.TrimSpace(req.Message),
- CreateAt: clock.NowUnixNano(),
- })
-}
-
-func (u *eventUseCase) ListByQueue(ctx context.Context, tenantID, ownerUID, accountID, queueID string, limit int) ([]domusecase.EventSummary, error) {
- items, err := u.repo.ListByQueue(ctx, tenantID, ownerUID, accountID, queueID, limit)
- if err != nil {
- return nil, err
- }
- return toSummaries(items), nil
-}
-
-func (u *eventUseCase) ListRecentByAccount(ctx context.Context, tenantID, ownerUID, accountID string, limit int) ([]domusecase.EventSummary, error) {
- items, err := u.repo.ListRecentByAccount(ctx, tenantID, ownerUID, accountID, limit)
- if err != nil {
- return nil, err
- }
- return toSummaries(items), nil
-}
-
-func toSummaries(items []entity.Event) []domusecase.EventSummary {
- out := make([]domusecase.EventSummary, 0, len(items))
- for _, item := range items {
- out = append(out, domusecase.EventSummary{
- ID: item.ID, QueueID: item.QueueID, AccountID: item.AccountID, EventType: item.EventType,
- FromStatus: item.FromStatus, ToStatus: item.ToStatus, Message: item.Message, CreateAt: item.CreateAt,
- })
- }
- return out
-}
diff --git a/old/backend/internal/model/scan_post/domain/entity/outreach_patch.go b/old/backend/internal/model/scan_post/domain/entity/outreach_patch.go
deleted file mode 100644
index 2cf8893..0000000
--- a/old/backend/internal/model/scan_post/domain/entity/outreach_patch.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package entity
-
-type OutreachPatch struct {
- Status string
- PublishedReplyID string
- PublishedPermalink string
- ExternalID string
-}
diff --git a/old/backend/internal/model/scan_post/domain/entity/post.go b/old/backend/internal/model/scan_post/domain/entity/post.go
deleted file mode 100644
index 37227aa..0000000
--- a/old/backend/internal/model/scan_post/domain/entity/post.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package entity
-
-const CollectionName = "scan_posts"
-
-const FlowViral = "viral"
-const FlowPlacement = "placement"
-
-type ScanReply struct {
- ExternalID string `bson:"external_id,omitempty"`
- Author string `bson:"author,omitempty"`
- Text string `bson:"text"`
- Permalink string `bson:"permalink,omitempty"`
- LikeCount int `bson:"like_count,omitempty"`
- PostedAt string `bson:"posted_at,omitempty"`
-}
-
-type ScanPost struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- BrandID string `bson:"brand_id"`
- TopicID string `bson:"topic_id,omitempty"`
- LegacyPersonaID string `bson:"persona_id,omitempty"`
- CopyMissionID string `bson:"copy_mission_id,omitempty"`
- Flow string `bson:"flow,omitempty"`
- GraphID string `bson:"graph_id"`
- ScanJobID string `bson:"scan_job_id"`
- GraphNodeID string `bson:"graph_node_id"`
- SearchTag string `bson:"search_tag"`
- QueryDimension string `bson:"query_dimension"`
- RecencyDays int `bson:"recency_days,omitempty"`
- ExternalID string `bson:"external_id"`
- Permalink string `bson:"permalink"`
- Author string `bson:"author"`
- AuthorID string `bson:"author_id,omitempty"`
- AuthorAvatar string `bson:"author_avatar,omitempty"`
- AuthorVerified bool `bson:"author_verified,omitempty"`
- FollowerCount int `bson:"follower_count,omitempty"`
- AuthorFollowers int `bson:"author_followers,omitempty"`
- Text string `bson:"text"`
- Priority string `bson:"priority"`
- LikeCount int `bson:"like_count,omitempty"`
- ReplyCount int `bson:"reply_count,omitempty"`
- EngagementScore int `bson:"engagement_score,omitempty"`
- PlacementScore int `bson:"placement_score"`
- ProductFitScore int `bson:"product_fit_score"`
- SolvedByProduct bool `bson:"solved_by_product"`
- Source string `bson:"source"`
- OutreachStatus string `bson:"outreach_status,omitempty"`
- PublishedReplyID string `bson:"published_reply_id,omitempty"`
- PublishedPermalink string `bson:"published_permalink,omitempty"`
- OutreachUpdateAt int64 `bson:"outreach_update_at,omitempty"`
- PostedAt string `bson:"posted_at,omitempty"`
- Replies []ScanReply `bson:"replies,omitempty"`
- Embedding []float32 `bson:"embedding,omitempty"`
- SemanticScore int `bson:"semantic_score,omitempty"`
- EngagementPredicted int `bson:"engagement_predicted,omitempty"`
- AudienceQualityScore int `bson:"audience_quality_score,omitempty"`
- CreateAt int64 `bson:"create_at"`
-}
-
-const (
- OutreachStatusPending = "pending"
- OutreachStatusDrafted = "drafted"
- OutreachStatusPublished = "published"
- OutreachStatusHandled = "handled"
- OutreachStatusSkipped = "skipped"
-)
diff --git a/old/backend/internal/model/scan_post/domain/repository/repository.go b/old/backend/internal/model/scan_post/domain/repository/repository.go
deleted file mode 100644
index 8b9ac28..0000000
--- a/old/backend/internal/model/scan_post/domain/repository/repository.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/scan_post/domain/entity"
-)
-
-type ListFilter struct {
- BrandID string
- TopicID string
- Priority string
- ProductFitMin int
- SemanticMin int
- Recent7dOnly bool
- Limit int
-}
-
-type PersonaListFilter struct {
- PersonaID string
- CopyMissionID string
- Flow string
- Limit int
-}
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- ClearForPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
- UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string, posts []entity.ScanPost) (int, error)
- PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanJobID string, keepPermalinks []string) error
- ReplaceForScan(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, posts []entity.ScanPost) error
- ReplaceForViralScan(ctx context.Context, tenantID, ownerUID, personaID, copyMissionID, scanJobID string, posts []entity.ScanPost) error
- Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*entity.ScanPost, error)
- GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*entity.ScanPost, error)
- UpdateOutreach(ctx context.Context, tenantID, ownerUID, brandID, postID string, patch entity.OutreachPatch) (*entity.ScanPost, error)
- Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error
- DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error)
- List(ctx context.Context, tenantID, ownerUID string, filter ListFilter) ([]entity.ScanPost, error)
- ListForPersona(ctx context.Context, tenantID, ownerUID string, filter PersonaListFilter) ([]entity.ScanPost, error)
- CountByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (int, error)
- AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
-}
diff --git a/old/backend/internal/model/scan_post/domain/usecase/usecase.go b/old/backend/internal/model/scan_post/domain/usecase/usecase.go
deleted file mode 100644
index 93102eb..0000000
--- a/old/backend/internal/model/scan_post/domain/usecase/usecase.go
+++ /dev/null
@@ -1,133 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/library/placement"
-)
-
-type ScanReplySummary struct {
- ExternalID string
- Author string
- Text string
- Permalink string
- LikeCount int
- PostedAt string
-}
-
-type ScanPostSummary struct {
- ID string
- BrandID string
- PersonaID string
- CopyMissionID string
- Flow string
- GraphNodeID string
- SearchTag string
- QueryDimension string
- RecencyDays int
- ExternalID string
- Permalink string
- Author string
- AuthorID string
- AuthorAvatar string
- AuthorVerified bool
- FollowerCount int
- AuthorFollowers int
- Text string
- Priority string
- LikeCount int
- ReplyCount int
- EngagementScore int
- PlacementScore int
- ProductFitScore int
- SolvedByProduct bool
- Source string
- ScanJobID string
- OutreachStatus string
- PublishedReplyID string
- PublishedPermalink string
- OutreachUpdateAt int64
- PostedAt string
- Replies []ScanReplySummary
- Embedding []float32
- SemanticScore int
- EngagementPredicted int
- AudienceQualityScore int
- CreateAt int64
-}
-
-type ListRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- Priority string
- ProductFitMin int
- SemanticMin int
- Recent7dOnly bool
- Limit int
-}
-
-type ReplaceRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- GraphID string
- ScanJobID string
- Posts []placement.ScanCandidate
-}
-
-type ViralReplaceRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- CopyMissionID string
- ScanJobID string
- Posts []placement.ScanCandidate
-}
-
-type PersonaListRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- CopyMissionID string
- Limit int
-}
-
-type UpdateOutreachRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- PostID string
- Status string
- PublishedReplyID string
- PublishedPermalink string
- ExternalID string
-}
-
-type CheckpointRequest struct {
- TenantID string
- OwnerUID string
- BrandID string
- TopicID string
- GraphID string
- ScanJobID string
- Posts []placement.ScanCandidate
-}
-
-type UseCase interface {
- ClearPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error
- UpsertScanCheckpoint(ctx context.Context, req CheckpointRequest) (int, error)
- FinalizeScan(ctx context.Context, req ReplaceRequest) (int, error)
- ReplaceFromScan(ctx context.Context, req ReplaceRequest) (int, error)
- ReplaceFromViralScan(ctx context.Context, req ViralReplaceRequest) (int, error)
- ClearCopyMissionViralScan(ctx context.Context, tenantID, ownerUID, personaID, copyMissionID string) error
- Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*ScanPostSummary, error)
- GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*ScanPostSummary, error)
- UpdateOutreach(ctx context.Context, req UpdateOutreachRequest) (*ScanPostSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error
- DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error)
- List(ctx context.Context, req ListRequest) ([]ScanPostSummary, error)
- ListForPersona(ctx context.Context, req PersonaListRequest) ([]ScanPostSummary, error)
-}
diff --git a/old/backend/internal/model/scan_post/repository/mongo.go b/old/backend/internal/model/scan_post/repository/mongo.go
deleted file mode 100644
index d7bf37d..0000000
--- a/old/backend/internal/model/scan_post/repository/mongo.go
+++ /dev/null
@@ -1,546 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/model/scan_post/domain/entity"
- domrepo "haixun-backend/internal/model/scan_post/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- return libmongo.EnsureIndexes(ctx, r.collection, []mongo.IndexModel{
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "permalink", Value: 1}},
- Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"brand_id": bson.M{"$gt": ""}, "topic_id": bson.M{"$in": []interface{}{nil, ""}}})},
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "topic_id", Value: 1}, {Key: "permalink", Value: 1}},
- Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"topic_id": bson.M{"$gt": ""}})},
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "permalink", Value: 1}},
- Options: options.Index().SetUnique(true).SetPartialFilterExpression(bson.M{"persona_id": bson.M{"$gt": ""}})},
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "priority", Value: 1}},
- },
- {
- Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "priority", Value: 1}},
- },
- {
- Keys: bson.D{{Key: "scan_job_id", Value: 1}},
- },
- })
-}
-
-func brandOwnerFilter(tenantID, ownerUID, brandID string) bson.M {
- filter := bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- }
- for k, v := range libmongo.BrandScopeFilter(brandID) {
- filter[k] = v
- }
- return filter
-}
-
-func personaViralFilter(tenantID, ownerUID, personaID string) bson.M {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "persona_id": strings.TrimSpace(personaID),
- "flow": entity.FlowViral,
- }
-}
-
-func personaViralScopeFilter(tenantID, ownerUID, personaID, copyMissionID string) bson.M {
- filter := personaViralFilter(tenantID, ownerUID, personaID)
- copyMissionID = strings.TrimSpace(copyMissionID)
- if copyMissionID != "" {
- filter["copy_mission_id"] = copyMissionID
- } else {
- filter["$or"] = []bson.M{
- {"copy_mission_id": bson.M{"$exists": false}},
- {"copy_mission_id": ""},
- }
- }
- return filter
-}
-
-func (r *mongoRepository) ReplaceForViralScan(ctx context.Context, tenantID, ownerUID, personaID, copyMissionID, scanJobID string, posts []entity.ScanPost) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteMany(ctx, personaViralScopeFilter(tenantID, ownerUID, personaID, copyMissionID))
- if err != nil {
- return err
- }
- if len(posts) == 0 {
- return nil
- }
- docs := make([]any, 0, len(posts))
- for _, post := range posts {
- docs = append(docs, post)
- }
- _, err = r.collection.InsertMany(ctx, docs)
- return err
-}
-
-func placementWriteFilter(tenantID, ownerUID, brandID, topicID string) bson.M {
- topicID = strings.TrimSpace(topicID)
- if topicID != "" {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "topic_id": topicID,
- }
- }
- return brandOwnerFilter(tenantID, ownerUID, brandID)
-}
-
-func untaggedTopicFilter() bson.M {
- return bson.M{
- "$or": []bson.M{
- {"topic_id": bson.M{"$exists": false}},
- {"topic_id": ""},
- },
- }
-}
-
-func (r *mongoRepository) ClearForPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- topicID = strings.TrimSpace(topicID)
- filter := bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- }
- if topicID != "" {
- filter["topic_id"] = topicID
- } else {
- for k, v := range brandOwnerFilter(tenantID, ownerUID, brandID) {
- filter[k] = v
- }
- for k, v := range untaggedTopicFilter() {
- filter[k] = v
- }
- }
- _, err := r.collection.DeleteMany(ctx, filter)
- return err
-}
-
-func (r *mongoRepository) UpsertBatchForScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string, posts []entity.ScanPost) (int, error) {
- if r.collection == nil {
- return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- if len(posts) == 0 {
- return 0, nil
- }
- upserted := 0
- for _, post := range posts {
- permalink := strings.TrimSpace(post.Permalink)
- if permalink == "" {
- continue
- }
- writeTopicID := strings.TrimSpace(topicID)
- if writeTopicID == "" && strings.TrimSpace(post.TopicID) != "" {
- writeTopicID = strings.TrimSpace(post.TopicID)
- }
- filter := placementWriteFilter(tenantID, ownerUID, brandID, writeTopicID)
- filter["permalink"] = permalink
-
- var existing entity.ScanPost
- err := r.collection.FindOne(ctx, filter).Decode(&existing)
- if err == nil {
- post.ID = existing.ID
- post.ExternalID = placement.PreferReplyExternalID(existing.ExternalID, post.ExternalID)
- if existing.CreateAt > 0 {
- post.CreateAt = existing.CreateAt
- }
- if strings.TrimSpace(existing.OutreachStatus) != "" && existing.OutreachStatus != entity.OutreachStatusPending {
- post.OutreachStatus = existing.OutreachStatus
- post.PublishedReplyID = existing.PublishedReplyID
- post.PublishedPermalink = existing.PublishedPermalink
- post.OutreachUpdateAt = existing.OutreachUpdateAt
- }
- if strings.TrimSpace(existing.PostedAt) != "" && strings.TrimSpace(post.PostedAt) == "" {
- post.PostedAt = existing.PostedAt
- }
- }
- if writeTopicID != "" {
- post.TopicID = writeTopicID
- }
- if strings.TrimSpace(post.BrandID) == "" {
- post.BrandID = brandID
- }
- if strings.TrimSpace(post.ID) == "" {
- continue
- }
- if post.CreateAt == 0 {
- post.CreateAt = time.Now().UnixNano()
- }
-
- raw, err := bson.Marshal(post)
- if err != nil {
- return upserted, err
- }
- var doc bson.M
- if err := bson.Unmarshal(raw, &doc); err != nil {
- return upserted, err
- }
- delete(doc, "_id")
-
- _, err = r.collection.UpdateOne(
- ctx,
- filter,
- bson.M{"$set": doc, "$setOnInsert": bson.M{"_id": post.ID}},
- options.Update().SetUpsert(true),
- )
- if err != nil {
- return upserted, err
- }
- upserted++
- }
- return upserted, nil
-}
-
-func (r *mongoRepository) PruneScanJobPosts(ctx context.Context, tenantID, ownerUID, brandID, topicID, scanJobID string, keepPermalinks []string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- filter := placementWriteFilter(tenantID, ownerUID, brandID, topicID)
- filter["scan_job_id"] = strings.TrimSpace(scanJobID)
- if len(keepPermalinks) > 0 {
- filter["permalink"] = bson.M{"$nin": keepPermalinks}
- }
- _, err := r.collection.DeleteMany(ctx, filter)
- return err
-}
-
-func (r *mongoRepository) ReplaceForScan(ctx context.Context, tenantID, ownerUID, brandID, scanJobID string, posts []entity.ScanPost) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteMany(ctx, brandOwnerFilter(tenantID, ownerUID, brandID))
- if err != nil {
- return err
- }
- if len(posts) == 0 {
- return nil
- }
- docs := make([]any, 0, len(posts))
- for _, post := range posts {
- docs = append(docs, post)
- }
- _, err = r.collection.InsertMany(ctx, docs)
- return err
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*entity.ScanPost, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- filter := brandOwnerFilter(tenantID, ownerUID, brandID)
- filter["_id"] = strings.TrimSpace(postID)
- var out entity.ScanPost
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("scan post not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) UpdateOutreach(
- ctx context.Context,
- tenantID, ownerUID, brandID, postID string,
- patch entity.OutreachPatch,
-) (*entity.ScanPost, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- set := bson.M{}
- if strings.TrimSpace(patch.Status) != "" {
- set["outreach_status"] = strings.TrimSpace(patch.Status)
- }
- if strings.TrimSpace(patch.PublishedReplyID) != "" {
- set["published_reply_id"] = strings.TrimSpace(patch.PublishedReplyID)
- }
- if strings.TrimSpace(patch.PublishedPermalink) != "" {
- set["published_permalink"] = strings.TrimSpace(patch.PublishedPermalink)
- }
- if strings.TrimSpace(patch.ExternalID) != "" {
- set["external_id"] = strings.TrimSpace(patch.ExternalID)
- }
- if len(set) == 0 {
- return r.Get(ctx, tenantID, ownerUID, brandID, postID)
- }
- set["outreach_update_at"] = time.Now().UnixNano()
-
- filter := brandOwnerFilter(tenantID, ownerUID, brandID)
- filter["_id"] = strings.TrimSpace(postID)
- opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
- var out entity.ScanPost
- err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": set}, opts).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Brand).ResNotFound("scan post not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- postID = strings.TrimSpace(postID)
- if postID == "" {
- return app.For(code.Brand).InputMissingRequired("scan post id is required")
- }
- filter := topicScopeFilter(tenantID, ownerUID, topicID, brandID)
- filter["_id"] = postID
- result, err := r.collection.DeleteOne(ctx, filter)
- if err != nil {
- return err
- }
- if result.DeletedCount == 0 {
- return nil
- }
- return nil
-}
-
-func (r *mongoRepository) DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) {
- if r.collection == nil {
- return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- ids := make([]string, 0, len(postIDs))
- seen := map[string]struct{}{}
- for _, postID := range postIDs {
- id := strings.TrimSpace(postID)
- if id == "" {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- ids = append(ids, id)
- }
- if len(ids) == 0 {
- return 0, nil
- }
- filter := topicScopeFilter(tenantID, ownerUID, topicID, brandID)
- filter["_id"] = bson.M{"$in": ids}
- result, err := r.collection.DeleteMany(ctx, filter)
- if err != nil {
- return 0, err
- }
- return int(result.DeletedCount), nil
-}
-
-func (r *mongoRepository) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*entity.ScanPost, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- filter := personaViralFilter(tenantID, ownerUID, personaID)
- filter["_id"] = strings.TrimSpace(postID)
- var out entity.ScanPost
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("viral scan post not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) ListForPersona(ctx context.Context, tenantID, ownerUID string, filter domrepo.PersonaListFilter) ([]entity.ScanPost, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- query := personaViralScopeFilter(tenantID, ownerUID, filter.PersonaID, filter.CopyMissionID)
- if flow := strings.TrimSpace(filter.Flow); flow != "" {
- query["flow"] = flow
- }
- limit := filter.Limit
- if limit <= 0 {
- limit = 100
- }
- if limit > 500 {
- limit = 500
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "engagement_score", Value: -1}, {Key: "create_at", Value: -1}}).
- SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, query, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.ScanPost
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func topicScopeFilter(tenantID, ownerUID, topicID, brandID string) bson.M {
- topicID = strings.TrimSpace(topicID)
- if topicID != "" {
- return bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "topic_id": topicID,
- }
- }
- return brandOwnerFilter(tenantID, ownerUID, brandID)
-}
-
-func (r *mongoRepository) AttachTopicID(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
- if r.collection == nil {
- return app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- filter := brandOwnerFilter(tenantID, ownerUID, brandID)
- for k, v := range untaggedTopicFilter() {
- filter[k] = v
- }
- _, err := r.collection.UpdateMany(
- ctx,
- filter,
- bson.M{"$set": bson.M{"topic_id": strings.TrimSpace(topicID)}},
- )
- return err
-}
-
-func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, filter domrepo.ListFilter) ([]entity.ScanPost, error) {
- if r.collection == nil {
- return nil, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- query := topicScopeFilter(tenantID, ownerUID, filter.TopicID, filter.BrandID)
- if priorityQuery := priorityListFilter(filter.Priority); priorityQuery != nil {
- for key, value := range priorityQuery {
- query[key] = value
- }
- }
- if filter.ProductFitMin > 0 {
- query["product_fit_score"] = bson.M{"$gte": filter.ProductFitMin}
- }
- if filter.SemanticMin > 0 {
- query["semantic_score"] = bson.M{"$gte": filter.SemanticMin}
- }
- if filter.Recent7dOnly {
- for key, value := range recent7dListQuery(time.Now()) {
- query[key] = value
- }
- }
- limit := filter.Limit
- if limit <= 0 {
- limit = 100
- }
- if limit > 500 {
- limit = 500
- }
- opts := options.Find().
- SetSort(bson.D{{Key: "placement_score", Value: -1}, {Key: "create_at", Value: -1}}).
- SetLimit(int64(limit))
- cur, err := r.collection.Find(ctx, query, opts)
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.ScanPost
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- if filter.Recent7dOnly {
- now := time.Now()
- filtered := make([]entity.ScanPost, 0, len(out))
- for _, item := range out {
- if matchesRecent7dPost(item, now) {
- filtered = append(filtered, item)
- }
- }
- return filtered, nil
- }
- return out, nil
-}
-
-const recent7dWindowDays = placement.IdealMaxPostAgeDays
-
-// recent7dListQuery matches posts from the recency track or with posted_at in the last 7 days.
-func recent7dListQuery(now time.Time) bson.M {
- cutoff := now.AddDate(0, 0, -recent7dWindowDays).Format("2006-01-02")
- return bson.M{
- "$or": []bson.M{
- {"posted_at": bson.M{"$gte": cutoff}},
- {
- "$and": []bson.M{
- {"posted_at": bson.M{"$in": []interface{}{nil, ""}}},
- {"recency_days": bson.M{"$lte": recent7dWindowDays, "$gt": 0}},
- },
- },
- },
- }
-}
-
-func matchesRecent7dPost(item entity.ScanPost, now time.Time) bool {
- postedAt := strings.TrimSpace(item.PostedAt)
- if postedAt != "" {
- ts, ok := placement.ParsePostedAt(postedAt)
- if ok {
- cutoff := now.AddDate(0, 0, -recent7dWindowDays)
- return !ts.Before(cutoff)
- }
- }
- return item.RecencyDays > 0 && item.RecencyDays <= recent7dWindowDays
-}
-
-// priorityListFilter maps UI track filters to stored priority values.
-// gold posts hit both relevance and recency tracks, so they belong in either filter.
-func priorityListFilter(priority string) bson.M {
- switch strings.TrimSpace(priority) {
- case "relevant":
- return bson.M{"priority": bson.M{"$in": []string{"relevant", "gold"}}}
- case "recent":
- return bson.M{"priority": bson.M{"$in": []string{"recent", "gold"}}}
- case "gold":
- return bson.M{"priority": "gold"}
- default:
- return nil
- }
-}
-
-func (r *mongoRepository) CountByBrand(ctx context.Context, tenantID, ownerUID, brandID string) (int, error) {
- if r.collection == nil {
- return 0, app.For(code.Brand).DBUnavailable("Mongo is not configured")
- }
- count, err := r.collection.CountDocuments(ctx, brandOwnerFilter(tenantID, ownerUID, brandID))
- return int(count), err
-}
diff --git a/old/backend/internal/model/scan_post/repository/mongo_priority_filter_test.go b/old/backend/internal/model/scan_post/repository/mongo_priority_filter_test.go
deleted file mode 100644
index f9aca08..0000000
--- a/old/backend/internal/model/scan_post/repository/mongo_priority_filter_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package repository
-
-import (
- "testing"
-
- "go.mongodb.org/mongo-driver/bson"
-)
-
-func TestPriorityListFilterIncludesGoldInTrackFilters(t *testing.T) {
- relevant := priorityListFilter("relevant")
- inValues, ok := relevant["priority"].(bson.M)["$in"].([]string)
- if !ok {
- t.Fatalf("expected $in filter, got %v", relevant)
- }
- if len(inValues) != 2 || inValues[0] != "relevant" || inValues[1] != "gold" {
- t.Fatalf("unexpected relevant filter: %v", inValues)
- }
-
- recent := priorityListFilter("recent")
- inValues, ok = recent["priority"].(bson.M)["$in"].([]string)
- if !ok {
- t.Fatalf("expected $in filter, got %v", recent)
- }
- if len(inValues) != 2 || inValues[0] != "recent" || inValues[1] != "gold" {
- t.Fatalf("unexpected recent filter: %v", inValues)
- }
-
- gold := priorityListFilter("gold")
- if gold["priority"] != "gold" {
- t.Fatalf("expected exact gold filter, got %v", gold)
- }
-}
-
-func TestPriorityListFilterEmpty(t *testing.T) {
- if priorityListFilter("") != nil {
- t.Fatal("empty priority should not add filter")
- }
- if priorityListFilter("unknown") != nil {
- t.Fatal("unknown priority should not add filter")
- }
-}
diff --git a/old/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go b/old/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go
deleted file mode 100644
index 7735c30..0000000
--- a/old/backend/internal/model/scan_post/repository/mongo_recent7d_filter_test.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package repository
-
-import (
- "testing"
- "time"
-
- "haixun-backend/internal/model/scan_post/domain/entity"
-
- "go.mongodb.org/mongo-driver/bson"
-)
-
-func TestMatchesRecent7dPost_Includes7dWindowWithoutDate(t *testing.T) {
- now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
- item := entity.ScanPost{RecencyDays: 7}
- if !matchesRecent7dPost(item, now) {
- t.Fatal("expected 7d-window post without date to match recent_7d filter")
- }
-}
-
-func TestMatchesRecent7dPost_Excludes14dWindowWithoutDate(t *testing.T) {
- now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
- item := entity.ScanPost{RecencyDays: 14, QueryDimension: "recency"}
- if matchesRecent7dPost(item, now) {
- t.Fatal("expected 14d-window post without date to be excluded from recent_7d")
- }
-}
-
-func TestMatchesRecent7dPost_IncludesPostedWithin7Days(t *testing.T) {
- now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
- item := entity.ScanPost{
- Priority: "relevant",
- PostedAt: "2026-06-28",
- }
- if !matchesRecent7dPost(item, now) {
- t.Fatal("expected post within 7 days to match")
- }
-}
-
-func TestMatchesRecent7dPost_ExcludesOldRelevantWithoutDate(t *testing.T) {
- now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
- item := entity.ScanPost{Priority: "relevant", QueryDimension: "relevance"}
- if matchesRecent7dPost(item, now) {
- t.Fatal("expected old relevance-only post without date to be excluded")
- }
-}
-
-func TestRecent7dListQuery_HasExpectedClauses(t *testing.T) {
- now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
- query := recent7dListQuery(now)
- orClause, ok := query["$or"].([]bson.M)
- if !ok || len(orClause) != 2 {
- t.Fatalf("expected 2 OR branches, got %T %v", query["$or"], query["$or"])
- }
-}
diff --git a/old/backend/internal/model/scan_post/repository/mongo_topic_scope_test.go b/old/backend/internal/model/scan_post/repository/mongo_topic_scope_test.go
deleted file mode 100644
index 19d6c04..0000000
--- a/old/backend/internal/model/scan_post/repository/mongo_topic_scope_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package repository
-
-import (
- "testing"
-
- "go.mongodb.org/mongo-driver/bson"
-)
-
-func TestClearForPlacementScanFilter_ScopedTopic(t *testing.T) {
- filter := placementWriteFilter("tenant-1", "user-1", "brand-1", "topic-a")
- if filter["topic_id"] != "topic-a" {
- t.Fatalf("expected topic_id topic-a, got %v", filter["topic_id"])
- }
- if filter["tenant_id"] != "tenant-1" || filter["owner_uid"] != "user-1" {
- t.Fatalf("unexpected actor filter: %v", filter)
- }
- if _, ok := filter["brand_id"]; ok {
- t.Fatalf("topic-scoped filter must not include brand_id: %v", filter)
- }
-}
-
-func TestUntaggedTopicFilter_OnlyMissingTopicID(t *testing.T) {
- legacy := untaggedTopicFilter()
- orClause, ok := legacy["$or"].([]bson.M)
- if !ok || len(orClause) != 2 {
- t.Fatalf("expected two untagged topic clauses, got %v", legacy)
- }
-}
diff --git a/old/backend/internal/model/scan_post/usecase/errors.go b/old/backend/internal/model/scan_post/usecase/errors.go
deleted file mode 100644
index 766f24d..0000000
--- a/old/backend/internal/model/scan_post/usecase/errors.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package usecase
-
-import (
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
-)
-
-func errMissingActor() error {
- return app.For(code.Brand).InputMissingRequired("tenant_id and uid are required")
-}
-
-func errMissingBrand() error {
- return app.For(code.Brand).InputMissingRequired("brand id is required")
-}
diff --git a/old/backend/internal/model/scan_post/usecase/usecase.go b/old/backend/internal/model/scan_post/usecase/usecase.go
deleted file mode 100644
index 558eac2..0000000
--- a/old/backend/internal/model/scan_post/usecase/usecase.go
+++ /dev/null
@@ -1,414 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libmongo "haixun-backend/internal/library/mongo"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/model/scan_post/domain/entity"
- domrepo "haixun-backend/internal/model/scan_post/domain/repository"
- domusecase "haixun-backend/internal/model/scan_post/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type scanPostUseCase struct {
- repo domrepo.Repository
-}
-
-func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
- return &scanPostUseCase{repo: repo}
-}
-
-func (u *scanPostUseCase) ClearCopyMissionViralScan(ctx context.Context, tenantID, ownerUID, personaID, copyMissionID string) error {
- if err := requireViralActor(tenantID, ownerUID, personaID); err != nil {
- return err
- }
- copyMissionID = strings.TrimSpace(copyMissionID)
- if copyMissionID == "" {
- return app.For(code.Persona).InputMissingRequired("copy_mission_id is required")
- }
- return u.repo.ReplaceForViralScan(ctx, tenantID, ownerUID, personaID, copyMissionID, "", nil)
-}
-
-func (u *scanPostUseCase) ReplaceFromViralScan(ctx context.Context, req domusecase.ViralReplaceRequest) (int, error) {
- if err := requireViralActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return 0, err
- }
- now := clock.NowUnixNano()
- entities := make([]entity.ScanPost, 0, len(req.Posts))
- for _, item := range req.Posts {
- entities = append(entities, entity.ScanPost{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- LegacyPersonaID: req.PersonaID,
- CopyMissionID: strings.TrimSpace(req.CopyMissionID),
- Flow: entity.FlowViral,
- ScanJobID: req.ScanJobID,
- SearchTag: item.SearchTag,
- ExternalID: item.ExternalID,
- Permalink: item.Permalink,
- Author: item.Author,
- AuthorID: item.AuthorID,
- AuthorAvatar: item.AuthorAvatar,
- AuthorVerified: item.AuthorVerified,
- FollowerCount: item.FollowerCount,
- AuthorFollowers: item.AuthorFollowers,
- Text: item.Text,
- Priority: item.Priority,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- EngagementScore: item.EngagementScore,
- PlacementScore: item.PlacementScore,
- Embedding: item.Embedding,
- SemanticScore: item.SemanticScore,
- EngagementPredicted: item.EngagementPredicted,
- AudienceQualityScore: item.AudienceQualityScore,
- Source: string(item.Source),
- Replies: toReplyEntities(item.Replies),
- CreateAt: now,
- })
- }
- if err := u.repo.ReplaceForViralScan(ctx, req.TenantID, req.OwnerUID, req.PersonaID, req.CopyMissionID, req.ScanJobID, entities); err != nil {
- return 0, err
- }
- return len(entities), nil
-}
-
-func (u *scanPostUseCase) ClearPlacementScan(ctx context.Context, tenantID, ownerUID, brandID, topicID string) error {
- if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
- return err
- }
- return u.repo.ClearForPlacementScan(ctx, tenantID, ownerUID, brandID, topicID)
-}
-
-func (u *scanPostUseCase) UpsertScanCheckpoint(ctx context.Context, req domusecase.CheckpointRequest) (int, error) {
- if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
- return 0, err
- }
- entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts)
- return u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities)
-}
-
-func (u *scanPostUseCase) FinalizeScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) {
- if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
- return 0, err
- }
- entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts)
- count, err := u.repo.UpsertBatchForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, entities)
- if err != nil {
- return 0, err
- }
- // Do not prune checkpoint posts after finalize. Crawl checkpoints may include
- // candidates that fail strict final filters; pruning them made patrol results
- // disappear from the results tab right after a job completed.
- return count, nil
-}
-
-func (u *scanPostUseCase) ReplaceFromScan(ctx context.Context, req domusecase.ReplaceRequest) (int, error) {
- if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
- return 0, err
- }
- entities := placementCandidatesToEntities(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID, req.GraphID, req.ScanJobID, req.Posts)
- if err := u.repo.ReplaceForScan(ctx, req.TenantID, req.OwnerUID, req.BrandID, req.ScanJobID, entities); err != nil {
- return 0, err
- }
- return len(entities), nil
-}
-
-func placementCandidatesToEntities(tenantID, ownerUID, brandID, topicID, graphID, scanJobID string, posts []placement.ScanCandidate) []entity.ScanPost {
- now := clock.NowUnixNano()
- entities := make([]entity.ScanPost, 0, len(posts))
- for _, item := range posts {
- if strings.TrimSpace(item.Permalink) == "" {
- continue
- }
- entities = append(entities, entity.ScanPost{
- ID: uuid.NewString(),
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: brandID,
- TopicID: strings.TrimSpace(topicID),
- Flow: entity.FlowPlacement,
- GraphID: graphID,
- ScanJobID: scanJobID,
- GraphNodeID: item.GraphNodeID,
- SearchTag: item.SearchTag,
- QueryDimension: string(item.QueryDimension),
- RecencyDays: item.RecencyDays,
- ExternalID: item.ExternalID,
- Permalink: item.Permalink,
- Author: item.Author,
- AuthorID: item.AuthorID,
- AuthorAvatar: item.AuthorAvatar,
- AuthorFollowers: item.AuthorFollowers,
- Text: item.Text,
- Priority: item.Priority,
- PlacementScore: item.PlacementScore,
- ProductFitScore: item.ProductFitScore,
- SolvedByProduct: item.SolvedByProduct,
- Source: string(item.Source),
- OutreachStatus: entity.OutreachStatusPending,
- PostedAt: strings.TrimSpace(item.PostedAt),
- Replies: toReplyEntities(item.Replies),
- Embedding: item.Embedding,
- SemanticScore: item.SemanticScore,
- EngagementPredicted: item.EngagementPredicted,
- AudienceQualityScore: item.AudienceQualityScore,
- CreateAt: now,
- })
- }
- return entities
-}
-
-func (u *scanPostUseCase) Get(ctx context.Context, tenantID, ownerUID, brandID, postID string) (*domusecase.ScanPostSummary, error) {
- if err := requireActor(tenantID, ownerUID, brandID); err != nil {
- return nil, err
- }
- postID = strings.TrimSpace(postID)
- if postID == "" {
- return nil, errMissingBrand()
- }
- item, err := u.repo.Get(ctx, tenantID, ownerUID, brandID, postID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *scanPostUseCase) UpdateOutreach(ctx context.Context, req domusecase.UpdateOutreachRequest) (*domusecase.ScanPostSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID, req.BrandID); err != nil {
- return nil, err
- }
- postID := strings.TrimSpace(req.PostID)
- if postID == "" {
- return nil, errMissingBrand()
- }
- status := strings.TrimSpace(req.Status)
- if status == "" {
- return nil, app.For(code.Brand).InputMissingRequired("outreach status is required")
- }
- item, err := u.repo.UpdateOutreach(ctx, req.TenantID, req.OwnerUID, req.BrandID, postID, entity.OutreachPatch{
- Status: status,
- PublishedReplyID: req.PublishedReplyID,
- PublishedPermalink: req.PublishedPermalink,
- ExternalID: req.ExternalID,
- })
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *scanPostUseCase) Delete(ctx context.Context, tenantID, ownerUID, brandID, topicID, postID string) error {
- if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
- return err
- }
- postID = strings.TrimSpace(postID)
- if postID == "" {
- return app.For(code.Brand).InputMissingRequired("scan post id is required")
- }
- return u.repo.Delete(ctx, tenantID, ownerUID, brandID, topicID, postID)
-}
-
-func (u *scanPostUseCase) DeleteMany(ctx context.Context, tenantID, ownerUID, brandID, topicID string, postIDs []string) (int, error) {
- if err := requireListActor(tenantID, ownerUID, brandID, topicID); err != nil {
- return 0, err
- }
- ids := make([]string, 0, len(postIDs))
- seen := map[string]struct{}{}
- for _, postID := range postIDs {
- id := strings.TrimSpace(postID)
- if id == "" {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- seen[id] = struct{}{}
- ids = append(ids, id)
- }
- if len(ids) == 0 {
- return 0, app.For(code.Brand).InputMissingRequired("post_ids is required")
- }
- return u.repo.DeleteMany(ctx, tenantID, ownerUID, brandID, topicID, ids)
-}
-
-func (u *scanPostUseCase) GetForPersona(ctx context.Context, tenantID, ownerUID, personaID, postID string) (*domusecase.ScanPostSummary, error) {
- if err := requireViralActor(tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- postID = strings.TrimSpace(postID)
- if postID == "" {
- return nil, errMissingPersona()
- }
- item, err := u.repo.GetForPersona(ctx, tenantID, ownerUID, personaID, postID)
- if err != nil {
- return nil, err
- }
- summary := toSummary(*item)
- return &summary, nil
-}
-
-func (u *scanPostUseCase) ListForPersona(ctx context.Context, req domusecase.PersonaListRequest) ([]domusecase.ScanPostSummary, error) {
- if err := requireViralActor(req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListForPersona(ctx, req.TenantID, req.OwnerUID, domrepo.PersonaListFilter{
- PersonaID: req.PersonaID,
- CopyMissionID: req.CopyMissionID,
- Flow: entity.FlowViral,
- Limit: req.Limit,
- })
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.ScanPostSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func (u *scanPostUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.ScanPostSummary, error) {
- if err := requireListActor(req.TenantID, req.OwnerUID, req.BrandID, req.TopicID); err != nil {
- return nil, err
- }
- items, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, domrepo.ListFilter{
- BrandID: req.BrandID,
- TopicID: req.TopicID,
- Priority: req.Priority,
- ProductFitMin: req.ProductFitMin,
- SemanticMin: req.SemanticMin,
- Recent7dOnly: req.Recent7dOnly,
- Limit: req.Limit,
- })
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.ScanPostSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func requireActor(tenantID, ownerUID, brandID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return errMissingActor()
- }
- if strings.TrimSpace(brandID) == "" {
- return errMissingBrand()
- }
- return nil
-}
-
-func requireListActor(tenantID, ownerUID, brandID, topicID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return errMissingActor()
- }
- if strings.TrimSpace(topicID) == "" && strings.TrimSpace(brandID) == "" {
- return errMissingBrand()
- }
- return nil
-}
-
-func requireViralActor(tenantID, ownerUID, personaID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return errMissingActor()
- }
- if strings.TrimSpace(personaID) == "" {
- return errMissingPersona()
- }
- return nil
-}
-
-func errMissingPersona() error {
- return app.For(code.Persona).InputMissingRequired("persona_id is required")
-}
-
-func toReplyEntities(replies []placement.ReplyCandidate) []entity.ScanReply {
- if len(replies) == 0 {
- return nil
- }
- out := make([]entity.ScanReply, 0, len(replies))
- for _, reply := range replies {
- out = append(out, entity.ScanReply{
- ExternalID: reply.ExternalID,
- Author: reply.Author,
- Text: reply.Text,
- Permalink: reply.Permalink,
- LikeCount: reply.LikeCount,
- PostedAt: reply.PostedAt,
- })
- }
- return out
-}
-
-func toReplySummaries(replies []entity.ScanReply) []domusecase.ScanReplySummary {
- if len(replies) == 0 {
- return nil
- }
- out := make([]domusecase.ScanReplySummary, 0, len(replies))
- for _, reply := range replies {
- out = append(out, domusecase.ScanReplySummary{
- ExternalID: reply.ExternalID,
- Author: reply.Author,
- Text: reply.Text,
- Permalink: reply.Permalink,
- LikeCount: reply.LikeCount,
- PostedAt: reply.PostedAt,
- })
- }
- return out
-}
-
-func toSummary(item entity.ScanPost) domusecase.ScanPostSummary {
- return domusecase.ScanPostSummary{
- ID: item.ID,
- BrandID: libmongo.ResolveBrandID(item.BrandID, item.LegacyPersonaID),
- PersonaID: strings.TrimSpace(item.LegacyPersonaID),
- CopyMissionID: item.CopyMissionID,
- Flow: item.Flow,
- GraphNodeID: item.GraphNodeID,
- SearchTag: item.SearchTag,
- QueryDimension: item.QueryDimension,
- RecencyDays: item.RecencyDays,
- ExternalID: item.ExternalID,
- Permalink: item.Permalink,
- Author: item.Author,
- AuthorID: item.AuthorID,
- AuthorAvatar: item.AuthorAvatar,
- AuthorVerified: item.AuthorVerified,
- FollowerCount: item.FollowerCount,
- AuthorFollowers: item.AuthorFollowers,
- Text: item.Text,
- Priority: item.Priority,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- EngagementScore: item.EngagementScore,
- PlacementScore: item.PlacementScore,
- ProductFitScore: item.ProductFitScore,
- SolvedByProduct: item.SolvedByProduct,
- Source: item.Source,
- ScanJobID: item.ScanJobID,
- OutreachStatus: item.OutreachStatus,
- PublishedReplyID: item.PublishedReplyID,
- PublishedPermalink: item.PublishedPermalink,
- OutreachUpdateAt: item.OutreachUpdateAt,
- PostedAt: item.PostedAt,
- Replies: toReplySummaries(item.Replies),
- Embedding: item.Embedding,
- SemanticScore: item.SemanticScore,
- EngagementPredicted: item.EngagementPredicted,
- AudienceQualityScore: item.AudienceQualityScore,
- CreateAt: item.CreateAt,
- }
-}
diff --git a/old/backend/internal/model/setting/domain/entity/setting.go b/old/backend/internal/model/setting/domain/entity/setting.go
deleted file mode 100644
index b4c3b84..0000000
--- a/old/backend/internal/model/setting/domain/entity/setting.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package entity
-
-import "go.mongodb.org/mongo-driver/bson/primitive"
-
-const CollectionName = "settings"
-
-type Setting struct {
- ID primitive.ObjectID `bson:"_id,omitempty"`
- Scope string `bson:"scope"`
- ScopeID string `bson:"scope_id"`
- Key string `bson:"key"`
- Value map[string]any `bson:"value"`
- Version int `bson:"version"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/setting/domain/repository/setting.go b/old/backend/internal/model/setting/domain/repository/setting.go
deleted file mode 100644
index 6cfbf4e..0000000
--- a/old/backend/internal/model/setting/domain/repository/setting.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/setting/domain/entity"
-)
-
-type Repository interface {
- List(ctx context.Context, scope, scopeID string, offset, limit int64) ([]*entity.Setting, int64, error)
- Find(ctx context.Context, scope, scopeID, key string) (*entity.Setting, error)
- Upsert(ctx context.Context, setting *entity.Setting) (*entity.Setting, error)
- Delete(ctx context.Context, scope, scopeID, key string) error
- EnsureIndexes(ctx context.Context) error
-}
diff --git a/old/backend/internal/model/setting/domain/usecase/setting.go b/old/backend/internal/model/setting/domain/usecase/setting.go
deleted file mode 100644
index 20519f5..0000000
--- a/old/backend/internal/model/setting/domain/usecase/setting.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package usecase
-
-import (
- "context"
-
- "haixun-backend/internal/model/setting/domain/entity"
-)
-
-type UpsertRequest struct {
- Scope string
- ScopeID string
- Key string
- Value map[string]any
- Version int
-}
-
-type UseCase interface {
- List(ctx context.Context, scope, scopeID string, page, pageSize int64) ([]*entity.Setting, int64, int64, int64, error)
- Get(ctx context.Context, scope, scopeID, key string) (*entity.Setting, error)
- Upsert(ctx context.Context, req UpsertRequest) (*entity.Setting, error)
- Delete(ctx context.Context, scope, scopeID, key string) error
-}
diff --git a/old/backend/internal/model/setting/repository/mongo.go b/old/backend/internal/model/setting/repository/mongo.go
deleted file mode 100644
index 88211de..0000000
--- a/old/backend/internal/model/setting/repository/mongo.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/setting/domain/entity"
- domrepo "haixun-backend/internal/model/setting/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "scope", Value: 1}, {Key: "scope_id", Value: 1}, {Key: "key", Value: 1}},
- Options: options.Index().SetUnique(true),
- })
- return err
-}
-
-func (r *mongoRepository) List(ctx context.Context, scope, scopeID string, offset, limit int64) ([]*entity.Setting, int64, error) {
- if r.collection == nil {
- return nil, 0, app.For(code.Setting).DBUnavailable("Mongo is not configured")
- }
- filter := bson.M{"scope": scope, "scope_id": scopeID}
- total, err := r.collection.CountDocuments(ctx, filter)
- if err != nil {
- return nil, 0, err
- }
- cursor, err := r.collection.Find(
- ctx,
- filter,
- options.Find().SetSort(bson.D{{Key: "key", Value: 1}}).SetSkip(offset).SetLimit(limit),
- )
- if err != nil {
- return nil, 0, err
- }
- defer cursor.Close(ctx)
-
- var items []*entity.Setting
- if err := cursor.All(ctx, &items); err != nil {
- return nil, 0, err
- }
- return items, total, nil
-}
-
-func (r *mongoRepository) Find(ctx context.Context, scope, scopeID, key string) (*entity.Setting, error) {
- if r.collection == nil {
- return nil, app.For(code.Setting).DBUnavailable("Mongo is not configured")
- }
- var item entity.Setting
- err := r.collection.FindOne(ctx, identityFilter(scope, scopeID, key)).Decode(&item)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Setting).ResNotFound("setting not found")
- }
- if err != nil {
- return nil, err
- }
- return &item, nil
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, setting *entity.Setting) (*entity.Setting, error) {
- if r.collection == nil {
- return nil, app.For(code.Setting).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- setting.UpdateAt = now
- if setting.CreateAt == 0 {
- setting.CreateAt = now
- }
- if setting.Version <= 0 {
- setting.Version = 1
- }
-
- after := options.After
- result := r.collection.FindOneAndUpdate(
- ctx,
- identityFilter(setting.Scope, setting.ScopeID, setting.Key),
- bson.M{
- "$set": bson.M{
- "scope": setting.Scope,
- "scope_id": setting.ScopeID,
- "key": setting.Key,
- "value": setting.Value,
- "version": setting.Version,
- "update_at": setting.UpdateAt,
- },
- "$setOnInsert": bson.M{"create_at": setting.CreateAt},
- },
- &options.FindOneAndUpdateOptions{
- Upsert: ptr(true),
- ReturnDocument: &after,
- },
- )
-
- var saved entity.Setting
- if err := result.Decode(&saved); err != nil {
- return nil, err
- }
- return &saved, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, scope, scopeID, key string) error {
- if r.collection == nil {
- return app.For(code.Setting).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteOne(ctx, identityFilter(scope, scopeID, key))
- return err
-}
-
-func identityFilter(scope, scopeID, key string) bson.M {
- return bson.M{"scope": scope, "scope_id": scopeID, "key": key}
-}
-
-func ptr[T any](v T) *T {
- return &v
-}
diff --git a/old/backend/internal/model/setting/usecase/usecase.go b/old/backend/internal/model/setting/usecase/usecase.go
deleted file mode 100644
index 3d15f4e..0000000
--- a/old/backend/internal/model/setting/usecase/usecase.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/setting/domain/entity"
- domrepo "haixun-backend/internal/model/setting/domain/repository"
- domusecase "haixun-backend/internal/model/setting/domain/usecase"
-)
-
-type UseCase = domusecase.UseCase
-
-type settingUseCase struct {
- repo domrepo.Repository
-}
-
-const (
- defaultLimit = int64(50)
- maxLimit = int64(200)
-)
-
-func NewUseCase(repo domrepo.Repository) UseCase {
- return &settingUseCase{repo: repo}
-}
-
-func (u *settingUseCase) List(ctx context.Context, scope, scopeID string, page, pageSize int64) ([]*entity.Setting, int64, int64, int64, error) {
- if err := validateIdentity(scope, scopeID, "x"); err != nil {
- return nil, 0, 0, 0, err
- }
- if page <= 0 {
- page = 1
- }
- if pageSize <= 0 {
- pageSize = defaultLimit
- }
- if pageSize > maxLimit {
- pageSize = maxLimit
- }
- offset := (page - 1) * pageSize
- items, total, err := u.repo.List(ctx, scope, scopeID, offset, pageSize)
- return items, total, page, pageSize, err
-}
-
-func (u *settingUseCase) Get(ctx context.Context, scope, scopeID, key string) (*entity.Setting, error) {
- if err := validateIdentity(scope, scopeID, key); err != nil {
- return nil, err
- }
-
- return u.repo.Find(ctx, scope, scopeID, key)
-}
-
-func (u *settingUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*entity.Setting, error) {
- if err := validateIdentity(req.Scope, req.ScopeID, req.Key); err != nil {
- return nil, err
- }
- if req.Value == nil {
- return nil, app.For(code.Setting).InputMissingRequired("setting value must not be empty")
- }
- return u.repo.Upsert(ctx, &entity.Setting{
- Scope: req.Scope,
- ScopeID: req.ScopeID,
- Key: req.Key,
- Value: req.Value,
- Version: req.Version,
- })
-}
-
-func (u *settingUseCase) Delete(ctx context.Context, scope, scopeID, key string) error {
- if err := validateIdentity(scope, scopeID, key); err != nil {
- return err
- }
- return u.repo.Delete(ctx, scope, scopeID, key)
-}
-
-func validateIdentity(scope, scopeID, key string) error {
- switch scope {
- case "user", "account", "system":
- default:
- return app.For(code.Setting).InputInvalidFormat("invalid setting scope")
- }
- if strings.TrimSpace(scopeID) == "" || strings.TrimSpace(key) == "" {
- return app.For(code.Setting).InputMissingRequired("setting scope_id and key must not be empty")
- }
- return nil
-}
diff --git a/old/backend/internal/model/style_preset/domain/entity/preset.go b/old/backend/internal/model/style_preset/domain/entity/preset.go
deleted file mode 100644
index 43fc367..0000000
--- a/old/backend/internal/model/style_preset/domain/entity/preset.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package entity
-
-const CollectionName = "style_presets"
-
-type Preset struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- PersonaID string `bson:"persona_id"`
- Name string `bson:"name"`
- Tone string `bson:"tone,omitempty"`
- CTA []string `bson:"cta,omitempty"`
- BannedWords []string `bson:"banned_words,omitempty"`
- Notes string `bson:"notes,omitempty"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/style_preset/domain/repository/repository.go b/old/backend/internal/model/style_preset/domain/repository/repository.go
deleted file mode 100644
index 7cb0dcd..0000000
--- a/old/backend/internal/model/style_preset/domain/repository/repository.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/style_preset/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error)
- Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error)
- Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error
-}
diff --git a/old/backend/internal/model/style_preset/domain/usecase/usecase.go b/old/backend/internal/model/style_preset/domain/usecase/usecase.go
deleted file mode 100644
index 7486c20..0000000
--- a/old/backend/internal/model/style_preset/domain/usecase/usecase.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package usecase
-
-import "context"
-
-type PresetSummary struct {
- ID string
- PersonaID string
- Name string
- Tone string
- CTA []string
- BannedWords []string
- Notes string
- CreateAt int64
- UpdateAt int64
-}
-
-type UpsertRequest struct {
- TenantID string
- OwnerUID string
- PersonaID string
- PresetID string
- Name string
- Tone string
- CTA []string
- BannedWords []string
- Notes string
- Apply bool
-}
-
-type UseCase interface {
- List(ctx context.Context, tenantID, ownerUID, personaID string) ([]PresetSummary, error)
- Upsert(ctx context.Context, req UpsertRequest) (*PresetSummary, error)
- Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error
-}
diff --git a/old/backend/internal/model/style_preset/repository/mongo.go b/old/backend/internal/model/style_preset/repository/mongo.go
deleted file mode 100644
index 63385a6..0000000
--- a/old/backend/internal/model/style_preset/repository/mongo.go
+++ /dev/null
@@ -1,109 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/style_preset/domain/entity"
- domrepo "haixun-backend/internal/model/style_preset/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "persona_id", Value: 1}, {Key: "create_at", Value: -1}}},
- })
- return err
-}
-
-func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]entity.Preset, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- cur, err := r.collection.Find(ctx, personaFilter(tenantID, ownerUID, personaID), options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}))
- if err != nil {
- return nil, err
- }
- defer cur.Close(ctx)
- var out []entity.Preset
- if err := cur.All(ctx, &out); err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, personaID, presetID string) (*entity.Preset, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = strings.TrimSpace(presetID)
- var out entity.Preset
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err != nil {
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.Persona).ResNotFound("style preset not found")
- }
- return nil, err
- }
- return &out, nil
-}
-
-func (r *mongoRepository) Upsert(ctx context.Context, preset *entity.Preset) (*entity.Preset, error) {
- if r.collection == nil {
- return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- if preset == nil {
- return nil, app.For(code.Persona).InputMissingRequired("preset is required")
- }
- filter := personaFilter(preset.TenantID, preset.OwnerUID, preset.PersonaID)
- filter["_id"] = preset.ID
- _, err := r.collection.ReplaceOne(ctx, filter, preset, options.Replace().SetUpsert(true))
- if err != nil {
- return nil, err
- }
- return preset, nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error {
- if r.collection == nil {
- return app.For(code.Persona).DBUnavailable("Mongo is not configured")
- }
- filter := personaFilter(tenantID, ownerUID, personaID)
- filter["_id"] = strings.TrimSpace(presetID)
- res, err := r.collection.DeleteOne(ctx, filter)
- if err != nil {
- return err
- }
- if res.DeletedCount == 0 {
- return app.For(code.Persona).ResNotFound("style preset not found")
- }
- return nil
-}
-
-func personaFilter(tenantID, ownerUID, personaID string) bson.M {
- return bson.M{
- "tenant_id": strings.TrimSpace(tenantID),
- "owner_uid": strings.TrimSpace(ownerUID),
- "persona_id": strings.TrimSpace(personaID),
- }
-}
diff --git a/old/backend/internal/model/style_preset/usecase/usecase.go b/old/backend/internal/model/style_preset/usecase/usecase.go
deleted file mode 100644
index 94a7893..0000000
--- a/old/backend/internal/model/style_preset/usecase/usecase.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package usecase
-
-import (
- "context"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- "haixun-backend/internal/model/style_preset/domain/entity"
- domrepo "haixun-backend/internal/model/style_preset/domain/repository"
- domusecase "haixun-backend/internal/model/style_preset/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-type stylePresetUseCase struct {
- repo domrepo.Repository
- persona personadomain.UseCase
-}
-
-func NewUseCase(repo domrepo.Repository, persona personadomain.UseCase) domusecase.UseCase {
- return &stylePresetUseCase{repo: repo, persona: persona}
-}
-
-func (u *stylePresetUseCase) List(ctx context.Context, tenantID, ownerUID, personaID string) ([]domusecase.PresetSummary, error) {
- if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil {
- return nil, err
- }
- items, err := u.repo.List(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.PresetSummary, 0, len(items))
- for _, item := range items {
- out = append(out, toSummary(item))
- }
- return out, nil
-}
-
-func (u *stylePresetUseCase) Upsert(ctx context.Context, req domusecase.UpsertRequest) (*domusecase.PresetSummary, error) {
- if err := u.require(ctx, req.TenantID, req.OwnerUID, req.PersonaID); err != nil {
- return nil, err
- }
- name := strings.TrimSpace(req.Name)
- if name == "" {
- return nil, app.For(code.Persona).InputMissingRequired("name is required")
- }
- now := clock.NowUnixNano()
- id := strings.TrimSpace(req.PresetID)
- item := &entity.Preset{ID: id}
- if id == "" || id == "new" {
- item.ID = uuid.NewString()
- item.CreateAt = now
- } else if existing, err := u.repo.Get(ctx, req.TenantID, req.OwnerUID, req.PersonaID, id); err == nil {
- item = existing
- }
- item.TenantID = req.TenantID
- item.OwnerUID = req.OwnerUID
- item.PersonaID = req.PersonaID
- item.Name = name
- item.Tone = strings.TrimSpace(req.Tone)
- item.CTA = cleanList(req.CTA)
- item.BannedWords = cleanList(req.BannedWords)
- item.Notes = strings.TrimSpace(req.Notes)
- item.UpdateAt = now
- if item.CreateAt == 0 {
- item.CreateAt = now
- }
- saved, err := u.repo.Upsert(ctx, item)
- if err != nil {
- return nil, err
- }
- if req.Apply && u.persona != nil {
- profile := formatPresetProfile(saved)
- _, _ = u.persona.Update(ctx, personadomain.UpdateRequest{
- TenantID: req.TenantID, OwnerUID: req.OwnerUID, PersonaID: req.PersonaID,
- Patch: personadomain.PersonaPatch{StyleProfile: &profile},
- })
- }
- out := toSummary(*saved)
- return &out, nil
-}
-
-func (u *stylePresetUseCase) Delete(ctx context.Context, tenantID, ownerUID, personaID, presetID string) error {
- if err := u.require(ctx, tenantID, ownerUID, personaID); err != nil {
- return err
- }
- return u.repo.Delete(ctx, tenantID, ownerUID, personaID, presetID)
-}
-
-func (u *stylePresetUseCase) require(ctx context.Context, tenantID, ownerUID, personaID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.Auth).AuthUnauthorized("missing actor")
- }
- if strings.TrimSpace(personaID) == "" {
- return app.For(code.Persona).InputMissingRequired("persona_id is required")
- }
- if u.persona != nil {
- _, err := u.persona.Get(ctx, tenantID, ownerUID, personaID)
- return err
- }
- return nil
-}
-
-func toSummary(item entity.Preset) domusecase.PresetSummary {
- return domusecase.PresetSummary{
- ID: item.ID, PersonaID: item.PersonaID, Name: item.Name, Tone: item.Tone,
- CTA: append([]string(nil), item.CTA...), BannedWords: append([]string(nil), item.BannedWords...),
- Notes: item.Notes, CreateAt: item.CreateAt, UpdateAt: item.UpdateAt,
- }
-}
-
-func cleanList(values []string) []string {
- out := make([]string, 0, len(values))
- seen := map[string]struct{}{}
- for _, value := range values {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" {
- continue
- }
- if _, ok := seen[trimmed]; ok {
- continue
- }
- seen[trimmed] = struct{}{}
- out = append(out, trimmed)
- }
- return out
-}
-
-func formatPresetProfile(item *entity.Preset) string {
- return fmt.Sprintf("語調:%s\nCTA:%s\n禁用詞:%s\n備註:%s", item.Tone, strings.Join(item.CTA, "、"), strings.Join(item.BannedWords, "、"), item.Notes)
-}
diff --git a/old/backend/internal/model/threads_account/domain/entity/account.go b/old/backend/internal/model/threads_account/domain/entity/account.go
deleted file mode 100644
index 2e30028..0000000
--- a/old/backend/internal/model/threads_account/domain/entity/account.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package entity
-
-const CollectionName = "threads_accounts"
-
-type Status string
-
-const (
- StatusOpen Status = "open"
- StatusDeleted Status = "deleted"
-)
-
-const (
- KnownAccountStatusExcluded = "excluded"
-)
-
-// KnownAccountProfile stores cross-mission memory for a discovered username
-// (Phase 4). Keyed by lowercase username inside Account.KnownAccounts.
-type KnownAccountProfile struct {
- FirstSeenAt int64 `bson:"first_seen_at" json:"first_seen_at"`
- LastSeenAt int64 `bson:"last_seen_at" json:"last_seen_at"`
- Missions []string `bson:"missions,omitempty" json:"missions,omitempty"`
- PersonaIds []string `bson:"persona_ids,omitempty" json:"persona_ids,omitempty"`
- Tags []string `bson:"tags,omitempty" json:"tags,omitempty"`
- AvgEngagement float64 `bson:"avg_engagement,omitempty" json:"avg_engagement,omitempty"`
- SeenCount int `bson:"seen_count,omitempty" json:"seen_count,omitempty"`
- Status string `bson:"status,omitempty" json:"status,omitempty"`
-}
-
-type Account struct {
- ID string `bson:"_id"`
- TenantID string `bson:"tenant_id"`
- OwnerUID string `bson:"owner_uid"`
- DisplayName string `bson:"display_name,omitempty"`
- Username string `bson:"username,omitempty"`
- ThreadsUserID string `bson:"threads_user_id,omitempty"`
- PersonaID string `bson:"persona_id,omitempty"`
- KnownAccounts map[string]KnownAccountProfile `bson:"known_accounts,omitempty" json:"known_accounts,omitempty"`
- Status Status `bson:"status"`
- CreateAt int64 `bson:"create_at"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/threads_account/domain/entity/secrets.go b/old/backend/internal/model/threads_account/domain/entity/secrets.go
deleted file mode 100644
index c3dae65..0000000
--- a/old/backend/internal/model/threads_account/domain/entity/secrets.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package entity
-
-const SecretsCollectionName = "threads_account_secrets"
-
-type Secrets struct {
- AccountID string `bson:"_id"`
- BrowserStorageState string `bson:"browser_storage_state,omitempty"`
- APIAccessToken string `bson:"api_access_token,omitempty"`
- APITokenExpiresAt int64 `bson:"api_token_expires_at,omitempty"`
- UpdateAt int64 `bson:"update_at"`
-}
diff --git a/old/backend/internal/model/threads_account/domain/repository/repository.go b/old/backend/internal/model/threads_account/domain/repository/repository.go
deleted file mode 100644
index b50ca8a..0000000
--- a/old/backend/internal/model/threads_account/domain/repository/repository.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/model/threads_account/domain/entity"
-)
-
-type Repository interface {
- EnsureIndexes(ctx context.Context) error
- Create(ctx context.Context, account *entity.Account) (*entity.Account, error)
- FindByID(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error)
- FindByIDGlobal(ctx context.Context, accountID string) (*entity.Account, error)
- FindByThreadsUserID(ctx context.Context, tenantID, ownerUID, threadsUserID string) (*entity.Account, error)
- ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error)
- UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error)
- UpdateAPIProfile(ctx context.Context, tenantID, ownerUID, accountID, username, threadsUserID, displayName string) (*entity.Account, error)
- ClearAPIProfile(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error)
- SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error
- Delete(ctx context.Context, tenantID, ownerUID, accountID string) error
- SetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string, known map[string]entity.KnownAccountProfile) error
-}
-
-type SecretsRepository interface {
- EnsureIndexes(ctx context.Context) error
- FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error)
- SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error)
- SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error)
- ClearAPIAccessToken(ctx context.Context, accountID string) error
- DeleteByAccountID(ctx context.Context, accountID string) error
- ListAPIAccessTokensExpiringBefore(ctx context.Context, beforeNs int64) ([]*entity.Secrets, error)
-}
-
-type OAuthStateStore interface {
- Save(ctx context.Context, state string, payload OAuthStatePayload) error
- Consume(ctx context.Context, state string) (*OAuthStatePayload, error)
-}
-
-type OAuthStatePayload struct {
- TenantID string `json:"tenant_id"`
- OwnerUID string `json:"owner_uid"`
- AccountID string `json:"account_id,omitempty"`
-}
diff --git a/old/backend/internal/model/threads_account/domain/usecase/usecase.go b/old/backend/internal/model/threads_account/domain/usecase/usecase.go
deleted file mode 100644
index 4b13a39..0000000
--- a/old/backend/internal/model/threads_account/domain/usecase/usecase.go
+++ /dev/null
@@ -1,304 +0,0 @@
-package usecase
-
-import (
- "context"
- "encoding/json"
- "time"
-
- "haixun-backend/internal/library/placement"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- "haixun-backend/internal/model/threads_account/domain/entity"
-)
-
-type AccountSummary struct {
- ID string
- DisplayName string
- Username string
- ThreadsUserID string
- PersonaID string
- BrowserConnected bool
- ApiConnected bool
- APITokenExpiresAt int64
- Status string
- CreateAt int64
- UpdateAt int64
-}
-
-type TokenRefreshCandidate struct {
- AccountID string
- TenantID string
- OwnerUID string
- ExpiresAt int64
-}
-
-type ConnectionPrefs struct {
- SearchViaApi bool
- SearchSourceMode string
- PublishViaApi bool
- DevMode bool
- ScrapeReplies bool
- RepliesPerPost int
- PublishHeaded bool
- PlaywrightDebug bool
-}
-
-type ConnectionData struct {
- AccountID string
- AccountName string
- Username string
- BrowserConnected bool
- ApiConnected bool
- Prefs ConnectionPrefs
-}
-
-type CreateRequest struct {
- TenantID string
- OwnerUID string
- DisplayName string
- Activate bool
-}
-
-type UpdateAccountRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- DisplayName *string
- PersonaID *string
-}
-
-type UpdateConnectionRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- Prefs ConnectionPrefsPatch
-}
-
-type ImportBrowserSessionRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- StorageState string
-}
-
-type ImportBrowserSessionResult struct {
- AccountID string
- Username string
- Synced bool
- Valid bool
- Message string
- UpdateAt int64
-}
-
-type SecretsData struct {
- AccountID string
- APIAccessToken string
- APITokenExpiresAt int64
- UpdateAt int64
-}
-
-type BrowserSessionData struct {
- AccountID string
- StorageState string
- UpdateAt int64
-}
-
-type AiSettings struct {
- AccountID string
- Provider string
- Model string
- ResearchProvider string
- ResearchModel string
- ApiKeys map[string]string
- ApiKeysConfigured map[string]bool
-}
-
-type AiSettingsPatch struct {
- Provider *string
- Model *string
- ResearchProvider *string
- ResearchModel *string
- ApiKeys map[string]string
-}
-
-// WorkerAiCredential carries raw provider settings for internal workers (never expose to clients).
-type WorkerAiCredential struct {
- Provider string
- Model string
- APIKey string
-}
-
-type ThreadsPublishCredential struct {
- AccountID string
- ThreadsUserID string
- AccessToken string
-}
-
-type ConnectionPrefsPatch struct {
- SearchViaApi *bool
- SearchSourceMode *string
- PublishViaApi *bool
- DevMode *bool
- ScrapeReplies *bool
- RepliesPerPost *int
- PublishHeaded *bool
- PlaywrightDebug *bool
-}
-
-type ListResult struct {
- List []AccountSummary
- ActiveAccountID string
-}
-
-type OAuthStartResult struct {
- AuthorizeURL string
- State string
- AccountID string
-}
-
-type OAuthCallbackResult struct {
- AccountID string
- Username string
- ThreadsUserID string
- RedirectURL string
-}
-
-type OAuthDebugLogEntry struct {
- At int64
- Level string
- Stage string
- AccountID string
- Message string
-}
-
-type APISmokeTestItem struct {
- Scope string
- Name string
- Status string // ok, error, skip, manual
- Message string
- Count int
-}
-
-type APIPlaygroundRequest struct {
- Action string
- Query string
- Username string
- MediaID string
- ReplyID string
- ReplyToID string
- Text string
- ImageURL string
- ImageURLs []string
- Permalink string
- HintText string
- SkipPrime bool
- LocationQuery string
- Limit int
- Hide bool
- SearchType string
-}
-
-type APIPlaygroundResult struct {
- Action string
- Ok bool
- Message string
- Data json.RawMessage
-}
-
-type PostMetricCapability struct {
- Scope string
- Name string
- Metrics []string
- Trackable bool
- Note string
-}
-
-type InsightMetricItem struct {
- Name string
- Value int
-}
-
-type AccountInsightsBlock struct {
- Status string
- Message string
- Metrics []InsightMetricItem
-}
-
-type PostPerformanceItem struct {
- MediaID string
- Text string
- Permalink string
- Timestamp string
- MediaType string
- MediaURL string
- ThumbnailURL string
- TopicTag string
- Shortcode string
- LikeCount int
- ReplyCount int
- RepostCount int
- QuoteCount int
- Views int
- Shares int
- InsightsStatus string
- InsightsMessage string
-}
-
-type PostPerformanceResult struct {
- AccountID string
- Username string
- ThreadsUserID string
- FetchedAt int64
- Capabilities []PostMetricCapability
- AccountInsights AccountInsightsBlock
- Posts []PostPerformanceItem
-}
-
-type UpsertKnownAccountsFromScanRequest struct {
- TenantID string
- OwnerUID string
- AccountID string
- MissionID string
- PersonaID string
- SelectedTags []string
- SimilarAccounts []missionentity.SimilarAccount
-}
-
-type UseCase interface {
- List(ctx context.Context, tenantID, ownerUID string) (*ListResult, error)
- Create(ctx context.Context, req CreateRequest) (*AccountSummary, error)
- Get(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error)
- Update(ctx context.Context, req UpdateAccountRequest) (*AccountSummary, error)
- Activate(ctx context.Context, tenantID, ownerUID, accountID string) error
- Delete(ctx context.Context, tenantID, ownerUID, accountID string) error
- GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*ConnectionData, error)
- UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error)
- ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error)
- GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error)
- GetMemberAiSettings(ctx context.Context, tenantID, ownerUID string) (*AiSettings, error)
- UpdateMemberAiSettings(ctx context.Context, tenantID, ownerUID string, patch AiSettingsPatch) (*AiSettings, error)
- GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error)
- UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error)
- ResolveMemberAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, provider, overrideKey string) (string, error)
- ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error)
- ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
- ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
- ResolveMemberResearchAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)
- ResolveMemberPlacementContext(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberContext, error)
- ResolveMemberThreadsPublishCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
- ResolveMemberThreadsReplyCredential(ctx context.Context, tenantID, ownerUID string) (*ThreadsPublishCredential, error)
- SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*SecretsData, error)
- StartThreadsOAuth(ctx context.Context, tenantID, ownerUID, accountID string) (*OAuthStartResult, error)
- CompleteThreadsOAuthCallback(ctx context.Context, code, state string) (*OAuthCallbackResult, error)
- DisconnectThreadsAPI(ctx context.Context, tenantID, ownerUID, accountID string) (*AccountSummary, error)
- ListOAuthDebugLogs(limit int) []OAuthDebugLogEntry
- RecordOAuthDebug(level, stage, accountID, message string)
- RunAPISmokeTest(ctx context.Context, tenantID, ownerUID, accountID string) ([]APISmokeTestItem, error)
- RunAPIPlayground(ctx context.Context, tenantID, ownerUID, accountID string, req APIPlaygroundRequest) (*APIPlaygroundResult, error)
- ListPostPerformance(ctx context.Context, tenantID, ownerUID, accountID string, limit int) (*PostPerformanceResult, error)
- ResolveAccountAPIAccessToken(ctx context.Context, tenantID, ownerUID, accountID string) (string, error)
- RefreshAccountAPIAccessToken(ctx context.Context, tenantID, ownerUID, accountID string) (*SecretsData, error)
- ListTokenRefreshCandidates(ctx context.Context, leadTime time.Duration) ([]TokenRefreshCandidate, error)
- ResolveMemberCapabilities(ctx context.Context, tenantID, ownerUID string, research placement.ResearchSettings) (placement.MemberCapabilities, error)
- GetKnownAccounts(ctx context.Context, tenantID, ownerUID, accountID string) (map[string]entity.KnownAccountProfile, error)
- UpsertKnownAccountsFromScan(ctx context.Context, req UpsertKnownAccountsFromScanRequest) error
-}
diff --git a/old/backend/internal/model/threads_account/repository/mongo.go b/old/backend/internal/model/threads_account/repository/mongo.go
deleted file mode 100644
index 9336b67..0000000
--- a/old/backend/internal/model/threads_account/repository/mongo.go
+++ /dev/null
@@ -1,271 +0,0 @@
-package repository
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/threads_account/domain/entity"
- domrepo "haixun-backend/internal/model/threads_account/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type mongoRepository struct {
- collection *mongo.Collection
-}
-
-func NewMongoRepository(db *mongo.Database) domrepo.Repository {
- if db == nil {
- return &mongoRepository{}
- }
- return &mongoRepository{collection: db.Collection(entity.CollectionName)}
-}
-
-func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "update_at", Value: -1}}},
- {Keys: bson.D{{Key: "tenant_id", Value: 1}, {Key: "owner_uid", Value: 1}, {Key: "_id", Value: 1}}, Options: options.Index().SetUnique(true)},
- })
- return err
-}
-
-func (r *mongoRepository) Create(ctx context.Context, account *entity.Account) (*entity.Account, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- account.CreateAt = now
- account.UpdateAt = now
- if account.Status == "" {
- account.Status = entity.StatusOpen
- }
- _, err := r.collection.InsertOne(ctx, account)
- if err != nil {
- return nil, err
- }
- return account, nil
-}
-
-func (r *mongoRepository) FindByID(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) {
- return r.findOne(ctx, bson.M{
- "_id": strings.TrimSpace(accountID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "status": entity.StatusOpen,
- })
-}
-
-func (r *mongoRepository) FindByIDGlobal(ctx context.Context, accountID string) (*entity.Account, error) {
- return r.findOne(ctx, bson.M{
- "_id": strings.TrimSpace(accountID),
- "status": entity.StatusOpen,
- })
-}
-
-func (r *mongoRepository) FindByThreadsUserID(
- ctx context.Context,
- tenantID, ownerUID, threadsUserID string,
-) (*entity.Account, error) {
- return r.findOne(ctx, bson.M{
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "threads_user_id": strings.TrimSpace(threadsUserID),
- "status": entity.StatusOpen,
- })
-}
-
-func (r *mongoRepository) ListByOwner(ctx context.Context, tenantID, ownerUID string) ([]*entity.Account, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- cursor, err := r.collection.Find(
- ctx,
- bson.M{"tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- options.Find().SetSort(bson.D{{Key: "update_at", Value: -1}}),
- )
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Account
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-func (r *mongoRepository) UpdateShell(ctx context.Context, tenantID, ownerUID, accountID string, displayName, username, personaID *string) (*entity.Account, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- set := bson.M{"update_at": clock.NowUnixNano()}
- if displayName != nil {
- set["display_name"] = strings.TrimSpace(*displayName)
- }
- if username != nil {
- set["username"] = strings.TrimPrefix(strings.TrimSpace(*username), "@")
- }
- if personaID != nil {
- set["persona_id"] = strings.TrimSpace(*personaID)
- }
- var out entity.Account
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": set},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) UpdateAPIProfile(
- ctx context.Context,
- tenantID, ownerUID, accountID, username, threadsUserID, displayName string,
-) (*entity.Account, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- set := bson.M{
- "update_at": clock.NowUnixNano(),
- }
- if v := strings.TrimPrefix(strings.TrimSpace(username), "@"); v != "" {
- set["username"] = v
- }
- if v := strings.TrimSpace(threadsUserID); v != "" {
- set["threads_user_id"] = v
- }
- if v := strings.TrimSpace(displayName); v != "" {
- set["display_name"] = v
- }
- var out entity.Account
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": set},
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) ClearAPIProfile(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (*entity.Account, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Account
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{
- "$set": bson.M{"update_at": clock.NowUnixNano()},
- "$unset": bson.M{
- "username": "",
- "threads_user_id": "",
- },
- },
- options.FindOneAndUpdate().SetReturnDocument(options.After),
- ).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- return &out, err
-}
-
-func (r *mongoRepository) SoftDelete(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID, "status": entity.StatusOpen},
- bson.M{"$set": bson.M{"status": entity.StatusDeleted, "update_at": clock.NowUnixNano()}},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- return nil
-}
-
-func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- res, err := r.collection.DeleteOne(
- ctx,
- bson.M{"_id": accountID, "tenant_id": tenantID, "owner_uid": ownerUID},
- )
- if err != nil {
- return err
- }
- if res.DeletedCount == 0 {
- return app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- return nil
-}
-
-func (r *mongoRepository) SetKnownAccounts(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
- known map[string]entity.KnownAccountProfile,
-) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if known == nil {
- known = map[string]entity.KnownAccountProfile{}
- }
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{
- "_id": strings.TrimSpace(accountID),
- "tenant_id": tenantID,
- "owner_uid": ownerUID,
- "status": entity.StatusOpen,
- },
- bson.M{"$set": bson.M{
- "known_accounts": known,
- "update_at": clock.NowUnixNano(),
- }},
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- return nil
-}
-
-func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Account, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Account
- err := r.collection.FindOne(ctx, filter).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, app.For(code.ThreadsAccount).ResNotFound("threads account not found")
- }
- if err != nil {
- return nil, err
- }
- return &out, nil
-}
diff --git a/old/backend/internal/model/threads_account/repository/oauth_state_redis.go b/old/backend/internal/model/threads_account/repository/oauth_state_redis.go
deleted file mode 100644
index f5d7fc7..0000000
--- a/old/backend/internal/model/threads_account/repository/oauth_state_redis.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package repository
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- domrepo "haixun-backend/internal/model/threads_account/domain/repository"
-
- goredis "github.com/redis/go-redis/v9"
-)
-
-const oauthStateKeyPrefix = "threads_oauth_state:"
-const oauthStateTTL = 10 * time.Minute
-
-type oauthStateRedisStore struct {
- client *goredis.Client
-}
-
-func NewOAuthStateRedisStore(client *goredis.Client) domrepo.OAuthStateStore {
- return &oauthStateRedisStore{client: client}
-}
-
-func (s *oauthStateRedisStore) Save(ctx context.Context, state string, payload domrepo.OAuthStatePayload) error {
- if s.client == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Redis is not configured")
- }
- state = strings.TrimSpace(state)
- if state == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("oauth state is required")
- }
- raw, err := json.Marshal(payload)
- if err != nil {
- return err
- }
- return s.client.Set(ctx, oauthStateKeyPrefix+state, raw, oauthStateTTL).Err()
-}
-
-func (s *oauthStateRedisStore) Consume(ctx context.Context, state string) (*domrepo.OAuthStatePayload, error) {
- if s.client == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis is not configured")
- }
- state = strings.TrimSpace(state)
- if state == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("oauth state is required")
- }
- key := oauthStateKeyPrefix + state
- raw, err := s.client.GetDel(ctx, key).Bytes()
- if err == goredis.Nil {
- return nil, app.For(code.ThreadsAccount).ResNotFound("oauth state expired or invalid")
- }
- if err != nil {
- return nil, err
- }
- var payload domrepo.OAuthStatePayload
- if err := json.Unmarshal(raw, &payload); err != nil {
- return nil, fmt.Errorf("parse oauth state: %w", err)
- }
- if strings.TrimSpace(payload.TenantID) == "" || strings.TrimSpace(payload.OwnerUID) == "" {
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat("oauth state payload incomplete")
- }
- return &payload, nil
-}
diff --git a/old/backend/internal/model/threads_account/repository/secrets_mongo.go b/old/backend/internal/model/threads_account/repository/secrets_mongo.go
deleted file mode 100644
index dc527a9..0000000
--- a/old/backend/internal/model/threads_account/repository/secrets_mongo.go
+++ /dev/null
@@ -1,209 +0,0 @@
-package repository
-
-import (
- "context"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/library/crypto"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/model/threads_account/domain/entity"
- domrepo "haixun-backend/internal/model/threads_account/domain/repository"
-
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type secretsMongoRepository struct {
- collection *mongo.Collection
- cipher *crypto.Cipher
-}
-
-func NewSecretsMongoRepository(db *mongo.Database, cipher *crypto.Cipher) domrepo.SecretsRepository {
- if db == nil {
- return &secretsMongoRepository{cipher: cipher}
- }
- return &secretsMongoRepository{collection: db.Collection(entity.SecretsCollectionName), cipher: cipher}
-}
-
-func (r *secretsMongoRepository) EnsureIndexes(ctx context.Context) error {
- if r.collection == nil {
- return nil
- }
- _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{
- Keys: bson.D{{Key: "_id", Value: 1}},
- })
- return err
-}
-
-func (r *secretsMongoRepository) FindByAccountID(ctx context.Context, accountID string) (*entity.Secrets, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- var out entity.Secrets
- err := r.collection.FindOne(ctx, bson.M{"_id": accountID}).Decode(&out)
- if err == mongo.ErrNoDocuments {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- if r.cipher != nil && out.BrowserStorageState != "" {
- plain, decErr := r.cipher.Decrypt(out.BrowserStorageState)
- if decErr != nil {
- return nil, decErr
- }
- out.BrowserStorageState = plain
- }
- if r.cipher != nil && out.APIAccessToken != "" {
- plain, decErr := r.cipher.Decrypt(out.APIAccessToken)
- if decErr != nil {
- return nil, decErr
- }
- out.APIAccessToken = plain
- }
- return &out, nil
-}
-
-func (r *secretsMongoRepository) SaveBrowserStorageState(ctx context.Context, accountID, storageState string) (*entity.Secrets, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- storedValue := storageState
- if r.cipher != nil {
- enc, encErr := r.cipher.Encrypt(storageState)
- if encErr != nil {
- return nil, encErr
- }
- storedValue = enc
- }
- var out entity.Secrets
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": accountID},
- bson.M{
- "$set": bson.M{
- "browser_storage_state": storedValue,
- "update_at": now,
- },
- "$setOnInsert": bson.M{"_id": accountID},
- },
- options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
- ).Decode(&out)
- if err != nil {
- return nil, err
- }
- // Return plaintext to callers regardless of at-rest encryption.
- out.BrowserStorageState = storageState
- return &out, nil
-}
-
-func (r *secretsMongoRepository) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*entity.Secrets, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- storedToken := accessToken
- if r.cipher != nil {
- enc, encErr := r.cipher.Encrypt(accessToken)
- if encErr != nil {
- return nil, encErr
- }
- storedToken = enc
- }
- var out entity.Secrets
- err := r.collection.FindOneAndUpdate(
- ctx,
- bson.M{"_id": accountID},
- bson.M{
- "$set": bson.M{
- "api_access_token": storedToken,
- "api_token_expires_at": expiresAt,
- "update_at": now,
- },
- "$setOnInsert": bson.M{"_id": accountID},
- },
- options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
- ).Decode(&out)
- if err != nil {
- return nil, err
- }
- out.APIAccessToken = accessToken
- out.APITokenExpiresAt = expiresAt
- return &out, nil
-}
-
-func (r *secretsMongoRepository) ListAPIAccessTokensExpiringBefore(
- ctx context.Context,
- beforeNs int64,
-) ([]*entity.Secrets, error) {
- if r.collection == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- if beforeNs <= 0 {
- return nil, nil
- }
- cursor, err := r.collection.Find(ctx, bson.M{
- "api_access_token": bson.M{"$ne": ""},
- "api_token_expires_at": bson.M{
- "$gt": int64(0),
- "$lte": beforeNs,
- },
- })
- if err != nil {
- return nil, err
- }
- defer cursor.Close(ctx)
- var items []*entity.Secrets
- if err := cursor.All(ctx, &items); err != nil {
- return nil, err
- }
- for _, item := range items {
- if item == nil {
- continue
- }
- if r.cipher != nil && item.APIAccessToken != "" {
- plain, decErr := r.cipher.Decrypt(item.APIAccessToken)
- if decErr != nil {
- return nil, decErr
- }
- item.APIAccessToken = plain
- }
- }
- return items, nil
-}
-
-func (r *secretsMongoRepository) ClearAPIAccessToken(ctx context.Context, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- now := clock.NowUnixNano()
- res, err := r.collection.UpdateOne(
- ctx,
- bson.M{"_id": accountID},
- bson.M{
- "$set": bson.M{
- "api_access_token": "",
- "api_token_expires_at": int64(0),
- "update_at": now,
- },
- },
- )
- if err != nil {
- return err
- }
- if res.MatchedCount == 0 {
- return nil
- }
- return nil
-}
-
-func (r *secretsMongoRepository) DeleteByAccountID(ctx context.Context, accountID string) error {
- if r.collection == nil {
- return app.For(code.ThreadsAccount).DBUnavailable("Mongo is not configured")
- }
- _, err := r.collection.DeleteOne(ctx, bson.M{"_id": accountID})
- return err
-}
diff --git a/old/backend/internal/model/threads_account/usecase/ai_credentials.go b/old/backend/internal/model/threads_account/usecase/ai_credentials.go
deleted file mode 100644
index df047f8..0000000
--- a/old/backend/internal/model/threads_account/usecase/ai_credentials.go
+++ /dev/null
@@ -1,398 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- settingdomain "haixun-backend/internal/model/setting/domain/usecase"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-const keyAiCredentials = "ai.credentials"
-
-var knownAiProviders = []string{
- "opencode-go",
- "xai",
- "openai",
- "anthropic",
- "google",
-}
-
-func (u *threadsAccountUseCase) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AiSettings, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
- if err != nil {
- return nil, err
- }
- return toPublicAiSettings(account.ID, stored), nil
-}
-
-func (u *threadsAccountUseCase) GetMemberAiSettings(ctx context.Context, tenantID, ownerUID string) (*domusecase.AiSettings, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, "")
- if err != nil {
- return nil, err
- }
- return toPublicAiSettings("", stored), nil
-}
-
-func (u *threadsAccountUseCase) ResolveMemberAiCredential(
- ctx context.Context,
- tenantID, ownerUID string,
-) (*domusecase.WorkerAiCredential, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, "")
- if err != nil {
- return nil, err
- }
- provider := strings.TrimSpace(stored.Provider)
- model := strings.TrimSpace(stored.Model)
- apiKey := strings.TrimSpace(stored.ApiKeys[provider])
- if provider == "" || apiKey == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key")
- }
- return &domusecase.WorkerAiCredential{
- Provider: provider,
- Model: model,
- APIKey: apiKey,
- }, nil
-}
-
-func (u *threadsAccountUseCase) ResolveMemberResearchAiCredential(
- ctx context.Context,
- tenantID, ownerUID string,
-) (*domusecase.WorkerAiCredential, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, "")
- if err != nil {
- return nil, err
- }
- provider := strings.TrimSpace(stored.ResearchProvider)
- model := strings.TrimSpace(stored.ResearchModel)
- if provider == "" {
- provider = strings.TrimSpace(stored.Provider)
- }
- if model == "" {
- model = strings.TrimSpace(stored.Model)
- }
- apiKey := strings.TrimSpace(stored.ApiKeys[provider])
- if provider == "" || apiKey == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定研究用 AI API key")
- }
- return &domusecase.WorkerAiCredential{
- Provider: provider,
- Model: model,
- APIKey: apiKey,
- }, nil
-}
-
-func (u *threadsAccountUseCase) ResolveWorkerAiCredential(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (*domusecase.WorkerAiCredential, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
- if err != nil {
- return nil, err
- }
- provider := strings.TrimSpace(stored.ResearchProvider)
- model := strings.TrimSpace(stored.ResearchModel)
- if provider == "" {
- provider = strings.TrimSpace(stored.Provider)
- }
- if model == "" {
- model = strings.TrimSpace(stored.Model)
- }
- apiKey := strings.TrimSpace(stored.ApiKeys[provider])
- if apiKey == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("AI API key is not configured for provider " + provider)
- }
- return &domusecase.WorkerAiCredential{
- Provider: provider,
- Model: model,
- APIKey: apiKey,
- }, nil
-}
-
-func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
- ctx context.Context,
- tenantID, ownerUID, accountID, provider, overrideKey string,
-) (string, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return "", err
- }
- trimmedOverride := strings.TrimSpace(overrideKey)
- if trimmedOverride != "" && !isMaskedAPIKey(trimmedOverride) {
- return trimmedOverride, nil
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
- if err != nil {
- return "", err
- }
- apiKey := strings.TrimSpace(stored.ApiKeys[provider])
- if apiKey == "" {
- return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key")
- }
- return apiKey, nil
-}
-
-func (u *threadsAccountUseCase) ResolveMemberAiProviderAPIKey(
- ctx context.Context,
- tenantID, ownerUID, provider, overrideKey string,
-) (string, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return "", err
- }
- trimmedOverride := strings.TrimSpace(overrideKey)
- if trimmedOverride != "" && !isMaskedAPIKey(trimmedOverride) {
- return trimmedOverride, nil
- }
- stored, err := u.loadAiCredentials(ctx, ownerUID, "")
- if err != nil {
- return "", err
- }
- apiKey := strings.TrimSpace(stored.ApiKeys[provider])
- if apiKey == "" {
- return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key")
- }
- return apiKey, nil
-}
-
-func (u *threadsAccountUseCase) UpdateAiSettings(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
- patch domusecase.AiSettingsPatch,
-) (*domusecase.AiSettings, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- current, err := u.loadAiCredentials(ctx, ownerUID, account.ID)
- if err != nil {
- return nil, err
- }
- next := applyAiSettingsPatch(current, patch)
- if err := u.saveAiCredentials(ctx, ownerUID, next); err != nil {
- return nil, err
- }
- return toPublicAiSettings(account.ID, next), nil
-}
-
-func (u *threadsAccountUseCase) UpdateMemberAiSettings(
- ctx context.Context,
- tenantID, ownerUID string,
- patch domusecase.AiSettingsPatch,
-) (*domusecase.AiSettings, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- current, err := u.loadAiCredentials(ctx, ownerUID, "")
- if err != nil {
- return nil, err
- }
- next := applyAiSettingsPatch(current, patch)
- if err := u.saveAiCredentials(ctx, ownerUID, next); err != nil {
- return nil, err
- }
- return toPublicAiSettings("", next), nil
-}
-
-type aiCredentials struct {
- Provider string
- Model string
- ResearchProvider string
- ResearchModel string
- ApiKeys map[string]string
-}
-
-func defaultAiCredentials() aiCredentials {
- return aiCredentials{
- Provider: "opencode-go",
- Model: "deepseek-v4-pro",
- ResearchProvider: "opencode-go",
- ResearchModel: "deepseek-v4-flash",
- ApiKeys: map[string]string{},
- }
-}
-
-func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, accountID string) (aiCredentials, error) {
- defaults := defaultAiCredentials()
- setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials)
- if err == nil {
- return u.mergeAiCredentials(defaults, setting.Value)
- }
- if !isSettingNotFound(err) {
- return defaults, err
- }
- // Legacy: per-account storage migrates to user scope on first read.
- legacy, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyAiCredentials)
- if err != nil {
- if isSettingNotFound(err) {
- return defaults, nil
- }
- return defaults, err
- }
- creds, err := u.mergeAiCredentials(defaults, legacy.Value)
- if err != nil {
- return defaults, err
- }
- if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil {
- return creds, saveErr
- }
- return creds, nil
-}
-
-func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error {
- value, err := u.aiCredentialsToMap(creds)
- if err != nil {
- return err
- }
- _, err = u.settings.Upsert(ctx, settingdomain.UpsertRequest{
- Scope: settingScopeUser,
- ScopeID: ownerUID,
- Key: keyAiCredentials,
- Value: value,
- })
- return err
-}
-
-// mergeAiCredentials merges stored setting values onto defaults, decrypting any
-// at-rest encrypted API keys.
-func (u *threadsAccountUseCase) mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) (aiCredentials, error) {
- if value == nil {
- return defaults, nil
- }
- if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.Provider = v
- }
- if v, ok := value["model"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.Model = v
- }
- if v, ok := value["research_provider"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.ResearchProvider = v
- }
- if v, ok := value["research_model"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.ResearchModel = v
- }
- if raw, ok := value["api_keys"].(map[string]interface{}); ok {
- keys := map[string]string{}
- for provider, item := range raw {
- s, ok := item.(string)
- if !ok || strings.TrimSpace(s) == "" {
- continue
- }
- plain := strings.TrimSpace(s)
- if u.cipher != nil {
- decrypted, err := u.cipher.Decrypt(plain)
- if err != nil {
- return defaults, err
- }
- plain = strings.TrimSpace(decrypted)
- }
- keys[provider] = plain
- }
- defaults.ApiKeys = keys
- }
- return defaults, nil
-}
-
-func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials {
- if patch.Provider != nil && strings.TrimSpace(*patch.Provider) != "" {
- current.Provider = strings.TrimSpace(*patch.Provider)
- }
- if patch.Model != nil && strings.TrimSpace(*patch.Model) != "" {
- current.Model = strings.TrimSpace(*patch.Model)
- }
- if patch.ResearchProvider != nil && strings.TrimSpace(*patch.ResearchProvider) != "" {
- current.ResearchProvider = strings.TrimSpace(*patch.ResearchProvider)
- }
- if patch.ResearchModel != nil && strings.TrimSpace(*patch.ResearchModel) != "" {
- current.ResearchModel = strings.TrimSpace(*patch.ResearchModel)
- }
- if len(patch.ApiKeys) > 0 {
- if current.ApiKeys == nil {
- current.ApiKeys = map[string]string{}
- }
- for provider, value := range patch.ApiKeys {
- trimmed := strings.TrimSpace(value)
- if trimmed == "" || isMaskedAPIKey(trimmed) {
- continue
- }
- current.ApiKeys[provider] = trimmed
- }
- }
- return current
-}
-
-func (u *threadsAccountUseCase) aiCredentialsToMap(creds aiCredentials) (map[string]interface{}, error) {
- keys := map[string]interface{}{}
- for provider, value := range creds.ApiKeys {
- stored := value
- if u.cipher != nil {
- enc, err := u.cipher.Encrypt(value)
- if err != nil {
- return nil, err
- }
- stored = enc
- }
- keys[provider] = stored
- }
- return map[string]interface{}{
- "provider": creds.Provider,
- "model": creds.Model,
- "research_provider": creds.ResearchProvider,
- "research_model": creds.ResearchModel,
- "api_keys": keys,
- }, nil
-}
-
-func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings {
- masked := map[string]string{}
- configured := map[string]bool{}
- for _, provider := range knownAiProviders {
- raw := strings.TrimSpace(creds.ApiKeys[provider])
- configured[provider] = raw != ""
- if maskedValue := maskAPIKey(raw); maskedValue != "" {
- masked[provider] = maskedValue
- }
- }
- return &domusecase.AiSettings{
- AccountID: accountID,
- Provider: creds.Provider,
- Model: creds.Model,
- ResearchProvider: creds.ResearchProvider,
- ResearchModel: creds.ResearchModel,
- ApiKeys: masked,
- ApiKeysConfigured: configured,
- }
-}
-
-func maskAPIKey(key string) string {
- trimmed := strings.TrimSpace(key)
- if trimmed == "" {
- return ""
- }
- if len(trimmed) <= 4 {
- return "••••"
- }
- return "••••" + trimmed[len(trimmed)-4:]
-}
-
-func isMaskedAPIKey(value string) bool {
- return strings.HasPrefix(value, "••••")
-}
diff --git a/old/backend/internal/model/threads_account/usecase/api_playground.go b/old/backend/internal/model/threads_account/usecase/api_playground.go
deleted file mode 100644
index 3175448..0000000
--- a/old/backend/internal/model/threads_account/usecase/api_playground.go
+++ /dev/null
@@ -1,265 +0,0 @@
-package usecase
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-func (u *threadsAccountUseCase) RunAPIPlayground(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
- req domusecase.APIPlaygroundRequest,
-) (*domusecase.APIPlaygroundResult, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- client := libthreads.NewClient(token)
- if !client.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
- }
-
- action := strings.TrimSpace(strings.ToLower(req.Action))
- if action == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("action 為必填")
- }
-
- userID := strings.TrimSpace(account.ThreadsUserID)
- if userID == "" {
- if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
- userID = profile.ID.String()
- }
- }
-
- limit := req.Limit
- if limit <= 0 {
- limit = 10
- }
- if limit > 50 {
- limit = 50
- }
-
- result := &domusecase.APIPlaygroundResult{Action: action}
- var payload any
- var runErr error
-
- switch action {
- case "me":
- payload, runErr = libthreads.GetMeProfile(ctx, token)
- case "user_threads":
- if userID == "" {
- runErr = fmt.Errorf("缺少 threads_user_id")
- } else {
- payload, runErr = client.UserThreads(ctx, userID, limit)
- }
- case "keyword_search":
- q := strings.TrimSpace(req.Query)
- if q == "" {
- runErr = fmt.Errorf("query 為必填")
- } else {
- searchType := strings.TrimSpace(req.SearchType)
- if searchType == "" {
- searchType = "TOP"
- }
- payload, runErr = client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
- Query: q, Limit: limit, SearchType: searchType,
- })
- }
- case "profile_lookup":
- u := strings.TrimPrefix(strings.TrimSpace(req.Username), "@")
- if u == "" {
- runErr = fmt.Errorf("username 為必填")
- } else {
- payload, runErr = client.ProfileLookup(ctx, u)
- }
- case "mentions":
- if userID == "" {
- runErr = fmt.Errorf("缺少 threads_user_id")
- } else {
- payload, runErr = client.UserMentions(ctx, userID, limit)
- }
- case "insights":
- if userID == "" {
- runErr = fmt.Errorf("缺少 threads_user_id")
- } else {
- payload, runErr = client.UserInsights(ctx, userID, nil)
- }
- case "location_search":
- q := strings.TrimSpace(req.LocationQuery)
- if q == "" {
- q = strings.TrimSpace(req.Query)
- }
- if q == "" {
- runErr = fmt.Errorf("location 查詢字串為必填")
- } else {
- payload, runErr = client.LocationSearch(ctx, q, limit)
- }
- case "media_replies", "media_conversation":
- mid := strings.TrimSpace(req.MediaID)
- if mid == "" {
- runErr = fmt.Errorf("media_id 為必填")
- } else {
- if limit < 30 {
- limit = 30
- }
- payload, runErr = client.MediaConversation(ctx, mid, limit)
- }
- case "media_stats":
- mid := strings.TrimSpace(req.MediaID)
- if mid == "" {
- runErr = fmt.Errorf("media_id 為必填")
- } else {
- payload, runErr = client.GetMediaStats(ctx, mid)
- }
- case "media_detail":
- mid := strings.TrimSpace(req.MediaID)
- if mid == "" {
- runErr = fmt.Errorf("media_id 為必填")
- } else {
- payload, runErr = client.GetMediaDetail(ctx, mid)
- }
- case "media_insights":
- mid := strings.TrimSpace(req.MediaID)
- if mid == "" {
- runErr = fmt.Errorf("media_id 為必填")
- } else {
- payload, runErr = client.MediaInsights(ctx, mid, nil)
- }
- case "publish_text":
- text := strings.TrimSpace(req.Text)
- imageURLs := req.ImageURLs
- if userID == "" || (text == "" && len(imageURLs) == 0) {
- runErr = fmt.Errorf("threads_user_id 與 text 或 image 為必填")
- } else {
- payload, runErr = libthreads.PublishText(ctx, libthreads.PublishTextInput{
- ThreadsUserID: userID, AccessToken: token, Text: text, ImageURLs: imageURLs,
- })
- }
- case "prime_reply_target":
- replyTo := strings.TrimSpace(req.ReplyToID)
- permalink := strings.TrimSpace(req.Permalink)
- if replyTo == "" || permalink == "" {
- runErr = fmt.Errorf("reply_to_id 與 permalink 為必填")
- } else {
- hint := strings.TrimSpace(req.HintText)
- if hint == "" {
- hint = strings.TrimSpace(req.Query)
- }
- if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
- ExternalID: replyTo,
- Permalink: permalink,
- HintText: hint,
- }, replyTo); primeErr != nil {
- runErr = primeErr
- } else {
- payload = map[string]string{"primed": replyTo}
- }
- }
- case "publish_reply":
- text := strings.TrimSpace(req.Text)
- imageURLs := req.ImageURLs
- replyTo := strings.TrimSpace(req.ReplyToID)
- if userID == "" || replyTo == "" || (text == "" && len(imageURLs) == 0) {
- runErr = fmt.Errorf("threads_user_id、reply_to_id、text 或 image 為必填")
- } else {
- permalink := strings.TrimSpace(req.Permalink)
- if permalink != "" && !req.SkipPrime {
- hint := strings.TrimSpace(req.HintText)
- if hint == "" {
- hint = strings.TrimSpace(req.Query)
- }
- if primeErr := client.PrimeReplyTargetForPublish(ctx, libthreads.ResolveReplyMediaInput{
- ExternalID: replyTo,
- Permalink: permalink,
- HintText: hint,
- }, replyTo); primeErr != nil {
- runErr = fmt.Errorf("回覆他人貼文前需先透過 keyword_search 註冊目標:%w", primeErr)
- }
- }
- if runErr == nil {
- payload, runErr = libthreads.PublishReply(ctx, libthreads.PublishReplyInput{
- ThreadsUserID: userID, AccessToken: token, ReplyToID: replyTo, Text: text, ImageURLs: imageURLs,
- })
- }
- }
- case "delete_media":
- mid := strings.TrimSpace(req.MediaID)
- if mid == "" {
- runErr = fmt.Errorf("media_id 為必填")
- } else {
- runErr = client.DeleteMedia(ctx, mid)
- if runErr == nil {
- payload = map[string]string{"deleted": mid}
- }
- }
- case "hide_reply":
- rid := strings.TrimSpace(req.ReplyID)
- if rid == "" {
- runErr = fmt.Errorf("reply_id 為必填")
- } else {
- runErr = client.HideReply(ctx, rid, req.Hide)
- if runErr == nil {
- payload = map[string]any{"reply_id": rid, "hide": req.Hide}
- }
- }
- case "share_to_instagram":
- mid := strings.TrimSpace(req.MediaID)
- if mid == "" {
- runErr = fmt.Errorf("media_id 為必填")
- } else {
- runErr = client.ShareToInstagram(ctx, mid)
- if runErr == nil {
- payload = map[string]string{"crossposted": mid}
- }
- }
- case "refresh_token":
- refreshed, rerr := libthreads.RefreshAccessToken(ctx, token)
- if rerr != nil {
- runErr = rerr
- } else {
- expiresAt := int64(0)
- if refreshed.ExpiresIn >= 300 {
- expiresAt = libthreads.NsFromNow(refreshed.ExpiresIn)
- }
- if _, serr := u.secretsRepo.SaveAPIAccessToken(ctx, account.ID, refreshed.AccessToken, expiresAt); serr != nil {
- runErr = serr
- } else {
- payload = map[string]any{
- "expires_in": refreshed.ExpiresIn,
- "saved": true,
- }
- }
- }
- default:
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat("不支援的 action: " + action)
- }
-
- if runErr != nil {
- result.Ok = false
- result.Message = runErr.Error()
- if libthreads.IsPermissionError(runErr) {
- result.Message += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)"
- }
- return result, nil
- }
-
- raw, err := json.Marshal(payload)
- if err != nil {
- return nil, err
- }
- result.Ok = true
- result.Message = "ok"
- result.Data = raw
- return result, nil
-}
diff --git a/old/backend/internal/model/threads_account/usecase/api_smoke.go b/old/backend/internal/model/threads_account/usecase/api_smoke.go
deleted file mode 100644
index ecb011e..0000000
--- a/old/backend/internal/model/threads_account/usecase/api_smoke.go
+++ /dev/null
@@ -1,168 +0,0 @@
-package usecase
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-func (u *threadsAccountUseCase) RunAPISmokeTest(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) ([]domusecase.APISmokeTestItem, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- client := libthreads.NewClient(token)
- if !client.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API token 無效")
- }
-
- var out []domusecase.APISmokeTestItem
- add := func(scope, name, status, message string, count int) {
- out = append(out, domusecase.APISmokeTestItem{
- Scope: scope, Name: name, Status: status, Message: message, Count: count,
- })
- }
- run := func(scope, name string, fn func() (int, string, error)) {
- count, detail, err := fn()
- if err != nil {
- msg := err.Error()
- if libthreads.IsPermissionError(err) {
- msg += "(請確認 Meta App 已授權此 scope,且帳號在測試人員名單)"
- }
- add(scope, name, "error", msg, 0)
- return
- }
- if detail != "" {
- add(scope, name, "ok", detail, count)
- return
- }
- add(scope, name, "ok", fmt.Sprintf("成功(%d 筆)", count), count)
- }
-
- profile, profileErr := libthreads.GetMeProfile(ctx, token)
- userID := strings.TrimSpace(account.ThreadsUserID)
- username := strings.TrimSpace(account.Username)
- if profileErr == nil && profile != nil {
- if userID == "" {
- userID = profile.ID.String()
- }
- if username == "" {
- username = profile.Username
- }
- }
-
- run("threads_basic", "GET /me 個人資料", func() (int, string, error) {
- if profileErr != nil {
- return 0, "", profileErr
- }
- return 1, "user_id=" + profile.ID.String() + " @" + profile.Username, nil
- })
-
- if userID == "" {
- add("threads_basic", "GET /{user-id}/threads 貼文列表", "error", "缺少 threads_user_id,請重新 OAuth", 0)
- } else {
- var sampleMediaID string
- run("threads_basic", "GET /{user-id}/threads 貼文列表", func() (int, string, error) {
- items, err := client.UserThreads(ctx, userID, 5)
- if err != nil {
- return 0, "", err
- }
- if len(items) > 0 {
- sampleMediaID = items[0].ID
- }
- return len(items), "", nil
- })
- if sampleMediaID != "" {
- run("threads_manage_insights", "GET /{media-id}/insights 貼文互動", func() (int, string, error) {
- stats, err := client.GetMediaStats(ctx, sampleMediaID)
- if err != nil {
- return 0, "", err
- }
- return 1, fmt.Sprintf("likes=%d replies=%d views=%d", stats.LikeCount, stats.ReplyCount, stats.Views), nil
- })
- run("threads_read_replies", "GET /{media-id}/replies 留言列表", func() (int, string, error) {
- items, err := client.MediaReplies(ctx, sampleMediaID, 5)
- if err != nil {
- return 0, "", err
- }
- return len(items), "", nil
- })
- } else {
- add("threads_manage_insights", "GET /{media-id}/insights 貼文互動", "skip", "帳號尚無貼文,略過", 0)
- add("threads_read_replies", "GET /{media-id}/replies 留言列表", "skip", "帳號尚無貼文,略過", 0)
- }
- }
-
- run("threads_keyword_search", "GET /keyword_search 關鍵字搜尋", func() (int, string, error) {
- items, err := client.KeywordSearch(ctx, libthreads.KeywordSearchOptions{
- Query: "threads", Limit: 5, SearchType: "TOP",
- })
- if err != nil {
- return 0, "", err
- }
- return len(items), "", nil
- })
-
- if username != "" {
- run("threads_profile_discovery", "GET /profile_lookup 帳號查詢", func() (int, string, error) {
- res, err := client.ProfileLookup(ctx, username)
- if err != nil {
- return 0, "", err
- }
- return 1, "@" + res.Username + " name=" + res.Name, nil
- })
- } else {
- add("threads_profile_discovery", "GET /profile_lookup 帳號查詢", "skip", "缺少 username,略過", 0)
- }
-
- if userID != "" {
- run("threads_manage_mentions", "GET /{user-id}/mentions 提及", func() (int, string, error) {
- items, err := client.UserMentions(ctx, userID, 5)
- if err != nil {
- return 0, "", err
- }
- return len(items), "", nil
- })
- run("threads_manage_insights", "GET /{user-id}/threads_insights 洞察", func() (int, string, error) {
- items, err := client.UserInsights(ctx, userID, nil)
- if err != nil {
- return 0, "", err
- }
- return len(items), fmt.Sprintf("%d 項指標", len(items)), nil
- })
- } else {
- add("threads_manage_mentions", "GET /{user-id}/mentions 提及", "skip", "缺少 threads_user_id", 0)
- add("threads_manage_insights", "GET /{user-id}/threads_insights 洞察", "skip", "缺少 threads_user_id", 0)
- }
-
- run("threads_location_tagging", "GET /location_search 地點搜尋", func() (int, string, error) {
- items, err := client.LocationSearch(ctx, "Taipei", 5)
- if err != nil {
- return 0, "", err
- }
- return len(items), "", nil
- })
-
- add("threads_content_publish", "POST 發文 / 回覆", "manual",
- "請用產品 API:POST /personas/:id/copy-drafts/:draftId/publish 或 outreach-drafts/publish", 0)
- add("threads_delete", "DELETE /{media-id} 刪文", "manual",
- "會真的刪除貼文,請在 Threads 後台或自行呼叫 DeleteMedia 測試", 0)
- add("threads_manage_replies", "POST 隱藏留言", "manual",
- "會改變留言狀態,請用 HideReply(reply_id) 手動測試", 0)
- add("threads_share_to_instagram", "POST 同步到 IG", "manual",
- "需已連結 IG 且會真的 crosspost,請用 ShareToInstagram(media_id) 手動測試", 0)
-
- return out, nil
-}
diff --git a/old/backend/internal/model/threads_account/usecase/connection_prefs_test.go b/old/backend/internal/model/threads_account/usecase/connection_prefs_test.go
deleted file mode 100644
index 2f259e5..0000000
--- a/old/backend/internal/model/threads_account/usecase/connection_prefs_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package usecase
-
-import (
- "testing"
-
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-func TestApplyConnectionPatchSearchSourceInFormalMode(t *testing.T) {
- current := formalConnectionPrefs()
- patch := domusecase.ConnectionPrefsPatch{
- SearchSourceMode: strPtr("threads_crawler"),
- }
- next := applyConnectionPatch(current, patch)
- if next.SearchSourceMode != "threads_brave" {
- t.Fatalf("expected crawler mode stripped to threads_brave, got %s", next.SearchSourceMode)
- }
- if next.DevMode {
- t.Fatal("expected dev_mode to stay disabled")
- }
-}
-
-func TestNormalizeConnectionPrefsKeepsScrapeReplies(t *testing.T) {
- prefs := formalConnectionPrefs()
- prefs.ScrapeReplies = true
- next := normalizeConnectionPrefs(prefs)
- if !next.ScrapeReplies {
- t.Fatal("expected scrape_replies to persist")
- }
- if next.PublishViaApi != true || !next.SearchViaApi {
- t.Fatal("expected formal API routing")
- }
-}
-
-func TestApplyConnectionPatchIgnoresLegacyDevMode(t *testing.T) {
- current := formalConnectionPrefs()
- enabled := true
- next := applyConnectionPatch(current, domusecase.ConnectionPrefsPatch{
- DevMode: &enabled,
- })
- if next.DevMode {
- t.Fatal("account dev_mode should not enable crawler routing")
- }
-}
-
-func strPtr(v string) *string {
- return &v
-}
diff --git a/old/backend/internal/model/threads_account/usecase/known_accounts.go b/old/backend/internal/model/threads_account/usecase/known_accounts.go
deleted file mode 100644
index 1472569..0000000
--- a/old/backend/internal/model/threads_account/usecase/known_accounts.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/library/placement"
- libviral "haixun-backend/internal/library/viral"
- accountentity "haixun-backend/internal/model/threads_account/domain/entity"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-func (u *threadsAccountUseCase) GetKnownAccounts(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (map[string]accountentity.KnownAccountProfile, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- accountID = strings.TrimSpace(accountID)
- if accountID == "" {
- return map[string]accountentity.KnownAccountProfile{}, nil
- }
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- return cloneKnownAccounts(account.KnownAccounts), nil
-}
-
-func (u *threadsAccountUseCase) UpsertKnownAccountsFromScan(
- ctx context.Context,
- req domusecase.UpsertKnownAccountsFromScanRequest,
-) error {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return err
- }
- accountID := strings.TrimSpace(req.AccountID)
- if accountID == "" || len(req.SimilarAccounts) == 0 {
- return nil
- }
- account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, accountID)
- if err != nil {
- return err
- }
- now := clock.NowUnixNano()
- merged := libviral.MergeKnownAccountsFromScan(
- cloneKnownAccounts(account.KnownAccounts),
- req.SimilarAccounts,
- strings.TrimSpace(req.MissionID),
- strings.TrimSpace(req.PersonaID),
- req.SelectedTags,
- now,
- )
- return u.repo.SetKnownAccounts(ctx, req.TenantID, req.OwnerUID, accountID, merged)
-}
-
-func (u *threadsAccountUseCase) ResolveMemberCapabilities(
- ctx context.Context,
- tenantID, ownerUID string,
- research placement.ResearchSettings,
-) (placement.MemberCapabilities, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return placement.MemberCapabilities{}, err
- }
- member, err := u.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
- if err != nil {
- return placement.MemberCapabilities{}, err
- }
- _, aiErr := u.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- _, publishErr := u.ResolveMemberThreadsPublishCredential(ctx, tenantID, ownerUID)
- return placement.BuildMemberCapabilities(member, research, aiErr == nil, publishErr == nil), nil
-}
-
-func cloneKnownAccounts(in map[string]accountentity.KnownAccountProfile) map[string]accountentity.KnownAccountProfile {
- if len(in) == 0 {
- return map[string]accountentity.KnownAccountProfile{}
- }
- out := make(map[string]accountentity.KnownAccountProfile, len(in))
- for key, item := range in {
- out[key] = accountentity.KnownAccountProfile{
- FirstSeenAt: item.FirstSeenAt,
- LastSeenAt: item.LastSeenAt,
- Missions: append([]string(nil), item.Missions...),
- PersonaIds: append([]string(nil), item.PersonaIds...),
- Tags: append([]string(nil), item.Tags...),
- AvgEngagement: item.AvgEngagement,
- SeenCount: item.SeenCount,
- Status: item.Status,
- }
- }
- return out
-}
diff --git a/old/backend/internal/model/threads_account/usecase/oauth.go b/old/backend/internal/model/threads_account/usecase/oauth.go
deleted file mode 100644
index e0b07cf..0000000
--- a/old/backend/internal/model/threads_account/usecase/oauth.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/model/threads_account/domain/entity"
- domrepo "haixun-backend/internal/model/threads_account/domain/repository"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-func (u *threadsAccountUseCase) StartThreadsOAuth(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (*domusecase.OAuthStartResult, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if !u.oauth.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定(THREADS_APP_ID / THREADS_APP_SECRET / THREADS_REDIRECT_URI)")
- }
- if u.oauthState == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定,無法保存 OAuth state")
- }
-
- accountID = strings.TrimSpace(accountID)
- var account *entity.Account
- var err error
- if accountID != "" {
- account, err = u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- } else {
- existing, listErr := u.repo.ListByOwner(ctx, tenantID, ownerUID)
- if listErr != nil {
- return nil, listErr
- }
- created, createErr := u.Create(ctx, domusecase.CreateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- DisplayName: "Threads 帳號 " + itoa(len(existing)+1),
- Activate: false,
- })
- if createErr != nil {
- return nil, createErr
- }
- account, err = u.assertOwned(ctx, tenantID, ownerUID, created.ID)
- if err != nil {
- return nil, err
- }
- }
-
- state := uuid.NewString()
- if err := u.oauthState.Save(ctx, state, domrepo.OAuthStatePayload{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- AccountID: account.ID,
- }); err != nil {
- return nil, err
- }
- authorizeURL, err := libthreads.BuildAuthorizeURL(u.oauth, state)
- if err != nil {
- return nil, err
- }
- redirectURI := strings.TrimSpace(u.oauth.RedirectURI)
- if !strings.HasPrefix(strings.ToLower(redirectURI), "https://") {
- recordOAuthDebug("error", "start", account.ID, "redirect_uri must be https:// (Meta error 1349187): "+redirectURI)
- return nil, app.For(code.ThreadsAccount).InputInvalidFormat("THREADS_REDIRECT_URI 必須為 https://(Meta 會拒絕 http://,錯誤碼 1349187)")
- }
- recordOAuthDebug("info", "start", account.ID, "redirect_uri="+redirectURI)
- return &domusecase.OAuthStartResult{
- AuthorizeURL: authorizeURL,
- State: state,
- AccountID: account.ID,
- }, nil
-}
-
-func (u *threadsAccountUseCase) CompleteThreadsOAuthCallback(
- ctx context.Context,
- authCode, state string,
-) (*domusecase.OAuthCallbackResult, error) {
- if !u.oauth.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads OAuth 尚未設定")
- }
- if u.oauthState == nil {
- return nil, app.For(code.ThreadsAccount).DBUnavailable("Redis 未設定")
- }
- recordOAuthDebug("info", "callback", "", "received code len="+itoa(len(strings.TrimSpace(authCode)))+" state len="+itoa(len(strings.TrimSpace(state))))
- payload, err := u.oauthState.Consume(ctx, state)
- if err != nil {
- recordOAuthDebug("error", "consume_state", "", err.Error())
- return nil, err
- }
- short, err := libthreads.ExchangeCodeForToken(ctx, u.oauth, authCode)
- if err != nil {
- recordOAuthDebug("error", "exchange_code", payload.AccountID, err.Error())
- return nil, err
- }
- recordOAuthDebug("info", "exchange_code", payload.AccountID, "short-lived ok expires_in="+itoa(short.ExpiresIn))
- longLived, err := libthreads.ExchangeLongLivedToken(ctx, u.oauth, short.AccessToken)
- if err != nil {
- recordOAuthDebug("warn", "long_lived", payload.AccountID, "fallback short-lived: "+err.Error())
- longLived = short
- } else {
- recordOAuthDebug("info", "long_lived", payload.AccountID, "ok expires_in="+itoa(longLived.ExpiresIn))
- }
- account, err := u.assertOwned(ctx, payload.TenantID, payload.OwnerUID, payload.AccountID)
- if err != nil {
- return nil, err
- }
-
- profile, err := libthreads.GetMeProfile(ctx, longLived.AccessToken)
- threadsUserID := ""
- username := ""
- displayName := ""
- if err != nil {
- threadsUserID = libthreads.ThreadsUserIDFromToken(short, longLived)
- if threadsUserID == "" {
- recordOAuthDebug("error", "profile_me", payload.AccountID, err.Error())
- if libthreads.IsPermissionError(err) {
- return nil, app.For(code.ThreadsAccount).AuthForbidden(
- "Threads API 需要 threads_basic:請到 Meta App Dashboard → 應用程式角色,把你的 Threads 帳號加為「測試人員」或管理員,並確認已啟用「Access the Threads API」",
- )
- }
- return nil, err
- }
- recordOAuthDebug("warn", "profile_me", payload.AccountID, "fallback token user_id="+threadsUserID+": "+err.Error())
- displayName = strings.TrimSpace(account.DisplayName)
- if displayName == "" {
- displayName = "Threads " + threadsUserID
- }
- } else {
- threadsUserID = profile.ID.String()
- username = profile.Username
- recordOAuthDebug("info", "profile_me", payload.AccountID, "username="+username+" threads_user_id="+threadsUserID)
- displayName = strings.TrimSpace(profile.Name)
- if displayName == "" {
- displayName = "@" + strings.TrimPrefix(username, "@")
- }
- }
-
- if err := u.assertNoAPIBindingConflict(ctx, payload.TenantID, payload.OwnerUID, account.ID, threadsUserID); err != nil {
- return nil, err
- }
-
- updated, err := u.repo.UpdateAPIProfile(
- ctx,
- payload.TenantID,
- payload.OwnerUID,
- account.ID,
- username,
- threadsUserID,
- displayName,
- )
- if err != nil {
- return nil, err
- }
- expiresAt := int64(0)
- if longLived.ExpiresIn >= 300 {
- expiresAt = libthreads.NsFromNow(longLived.ExpiresIn)
- } else if longLived.ExpiresIn > 0 {
- recordOAuthDebug("warn", "expires_in", payload.AccountID, "suspicious expires_in="+itoa(longLived.ExpiresIn)+"s; store without expiry deadline")
- }
- if _, err := u.secretsRepo.SaveAPIAccessToken(ctx, updated.ID, longLived.AccessToken, expiresAt); err != nil {
- recordOAuthDebug("error", "save_token", updated.ID, err.Error())
- return nil, err
- }
- recordOAuthDebug("info", "success", updated.ID, "api_connected=true username="+updated.Username)
- if err := u.members.SetActiveThreadsAccountID(ctx, payload.TenantID, payload.OwnerUID, updated.ID); err != nil {
- return nil, err
- }
-
- redirectURL := strings.TrimSpace(u.oauth.SuccessRedirect)
- if redirectURL == "" {
- redirectURL = "/"
- }
- sep := "?"
- if strings.Contains(redirectURL, "?") {
- sep = "&"
- }
- return &domusecase.OAuthCallbackResult{
- AccountID: updated.ID,
- Username: updated.Username,
- ThreadsUserID: updated.ThreadsUserID,
- RedirectURL: redirectURL + sep + "threads_oauth=ok&account_id=" + updated.ID,
- }, nil
-}
-
-func (u *threadsAccountUseCase) DisconnectThreadsAPI(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (*domusecase.AccountSummary, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- if err := u.secretsRepo.ClearAPIAccessToken(ctx, account.ID); err != nil {
- return nil, err
- }
- if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, account.ID); err != nil {
- return nil, err
- }
- return u.toSummary(ctx, account)
-}
-
-func (u *threadsAccountUseCase) assertNoAPIBindingConflict(
- ctx context.Context,
- tenantID, ownerUID, accountID, threadsUserID string,
-) error {
- dup, err := u.repo.FindByThreadsUserID(ctx, tenantID, ownerUID, threadsUserID)
- if err != nil || dup == nil || dup.ID == accountID {
- return nil
- }
- secrets, err := u.secretsRepo.FindByAccountID(ctx, dup.ID)
- if err != nil {
- return err
- }
- if isValidAPIToken(secrets) {
- label := dup.DisplayName
- if label == "" && dup.Username != "" {
- label = "@" + dup.Username
- }
- if label == "" {
- label = dup.ID
- }
- return app.For(code.ThreadsAccount).ResConflict(
- "此 Threads 帳號已綁定在「" + label + "」;請先中斷該帳號 API,或選該帳號按「連線選中帳號」重新授權",
- )
- }
- if _, err := u.repo.ClearAPIProfile(ctx, tenantID, ownerUID, dup.ID); err != nil {
- return err
- }
- return nil
-}
-
-func isValidAPIToken(secrets *entity.Secrets) bool {
- if secrets == nil {
- return false
- }
- token := strings.TrimSpace(secrets.APIAccessToken)
- if token == "" {
- return false
- }
- if secrets.APITokenExpiresAt > 0 && secrets.APITokenExpiresAt <= clock.NowUnixNano() {
- return false
- }
- return true
-}
diff --git a/old/backend/internal/model/threads_account/usecase/oauth_debug_log.go b/old/backend/internal/model/threads_account/usecase/oauth_debug_log.go
deleted file mode 100644
index a0d8302..0000000
--- a/old/backend/internal/model/threads_account/usecase/oauth_debug_log.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package usecase
-
-import (
- "log"
- "sync"
-
- "haixun-backend/internal/library/clock"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-const oauthDebugLogCap = 80
-
-// OAuthDebugEntry is a recent OAuth event for Dev Console debugging.
-type OAuthDebugEntry struct {
- At int64 `json:"at"`
- Level string `json:"level"`
- Stage string `json:"stage"`
- AccountID string `json:"account_id,omitempty"`
- Message string `json:"message"`
-}
-
-var (
- oauthDebugMu sync.Mutex
- oauthDebugRing []OAuthDebugEntry
- oauthDebugSeq int
-)
-
-func recordOAuthDebug(level, stage, accountID, message string) {
- at := clock.NowUnixNano()
- entry := OAuthDebugEntry{
- At: at,
- Level: level,
- Stage: stage,
- AccountID: accountID,
- Message: message,
- }
- log.Printf("threads oauth [%s] %s account=%s %s", stage, level, accountID, message)
-
- oauthDebugMu.Lock()
- defer oauthDebugMu.Unlock()
- if len(oauthDebugRing) < oauthDebugLogCap {
- oauthDebugRing = append(oauthDebugRing, entry)
- } else {
- oauthDebugRing[oauthDebugSeq%oauthDebugLogCap] = entry
- }
- oauthDebugSeq++
-}
-
-func (u *threadsAccountUseCase) RecordOAuthDebug(level, stage, accountID, message string) {
- recordOAuthDebug(level, stage, accountID, message)
-}
-
-func (u *threadsAccountUseCase) ListOAuthDebugLogs(limit int) []domusecase.OAuthDebugLogEntry {
- raw := listOAuthDebugLogs(limit)
- out := make([]domusecase.OAuthDebugLogEntry, 0, len(raw))
- for _, item := range raw {
- out = append(out, domusecase.OAuthDebugLogEntry{
- At: item.At,
- Level: item.Level,
- Stage: item.Stage,
- AccountID: item.AccountID,
- Message: item.Message,
- })
- }
- return out
-}
-
-func listOAuthDebugLogs(limit int) []OAuthDebugEntry {
- if limit <= 0 || limit > oauthDebugLogCap {
- limit = oauthDebugLogCap
- }
- oauthDebugMu.Lock()
- defer oauthDebugMu.Unlock()
- n := len(oauthDebugRing)
- if n == 0 {
- return nil
- }
- out := make([]OAuthDebugEntry, 0, min(n, limit))
- if n < oauthDebugLogCap {
- start := n - min(n, limit)
- if start < 0 {
- start = 0
- }
- out = append(out, oauthDebugRing[start:n]...)
- return out
- }
- start := oauthDebugSeq - min(oauthDebugSeq, limit)
- if start < 0 {
- start = 0
- }
- for i := start; i < oauthDebugSeq; i++ {
- out = append(out, oauthDebugRing[i%oauthDebugLogCap])
- }
- return out
-}
-
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
diff --git a/old/backend/internal/model/threads_account/usecase/placement_context.go b/old/backend/internal/model/threads_account/usecase/placement_context.go
deleted file mode 100644
index deb35aa..0000000
--- a/old/backend/internal/model/threads_account/usecase/placement_context.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/placement"
-)
-
-func (u *threadsAccountUseCase) ResolveMemberPlacementContext(
- ctx context.Context,
- tenantID, ownerUID string,
- research placement.ResearchSettings,
-) (placement.MemberContext, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return placement.MemberContext{}, err
- }
- member, err := u.members.GetByUID(ctx, tenantID, ownerUID)
- if err != nil {
- return placement.MemberContext{}, err
- }
- activeID := member.ActiveThreadsAccountID
- prefs := defaultConnectionPrefs()
- apiConnected := false
- browserConnected := false
- apiToken := ""
- if activeID != "" {
- if account, err := u.assertOwned(ctx, tenantID, ownerUID, activeID); err == nil {
- loaded, err := u.loadConnectionPrefs(ctx, account.ID)
- if err == nil {
- prefs = loaded
- }
- browserConnected, apiConnected, _ = u.connectionFlags(ctx, account.ID)
- if apiConnected {
- if secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID); err == nil && secrets != nil {
- apiToken = strings.TrimSpace(secrets.APIAccessToken)
- }
- }
- }
- }
- ctxOut := placement.BuildMemberContext(
- tenantID,
- ownerUID,
- activeID,
- placement.ConnectionPrefsInput{
- DevMode: prefs.DevMode,
- SearchSourceMode: prefs.SearchSourceMode,
- },
- apiConnected,
- browserConnected,
- research,
- prefs.ScrapeReplies,
- prefs.RepliesPerPost,
- )
- ctxOut.ThreadsAPIAccessToken = apiToken
- return placement.MemberContextForAPIOnly(ctxOut), nil
-}
diff --git a/old/backend/internal/model/threads_account/usecase/placement_context_test.go b/old/backend/internal/model/threads_account/usecase/placement_context_test.go
deleted file mode 100644
index 3c04b5d..0000000
--- a/old/backend/internal/model/threads_account/usecase/placement_context_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package usecase
-
-import "testing"
-
-func TestConnectionFlagsAssignmentOrder(t *testing.T) {
- // connectionFlags returns (browserConnected, apiConnected).
- browserFromRepo, apiFromRepo := true, false
-
- var browserConnected, apiConnected bool
- browserConnected, apiConnected = browserFromRepo, apiFromRepo
-
- if !browserConnected {
- t.Fatal("browser flag should be true")
- }
- if apiConnected {
- t.Fatal("api flag should be false")
- }
-}
diff --git a/old/backend/internal/model/threads_account/usecase/post_performance.go b/old/backend/internal/model/threads_account/usecase/post_performance.go
deleted file mode 100644
index bb29605..0000000
--- a/old/backend/internal/model/threads_account/usecase/post_performance.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package usecase
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-func (u *threadsAccountUseCase) ListPostPerformance(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
- limit int,
-) (*domusecase.PostPerformanceResult, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- client := libthreads.NewClient(token)
- if !client.Enabled() {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("Threads API 未連線")
- }
-
- if limit <= 0 {
- limit = 15
- }
- if limit > 30 {
- limit = 30
- }
-
- userID := strings.TrimSpace(account.ThreadsUserID)
- username := strings.TrimSpace(account.Username)
- if profile, perr := libthreads.GetMeProfile(ctx, token); perr == nil && profile != nil {
- if userID == "" {
- userID = profile.ID.String()
- }
- if username == "" {
- username = profile.Username
- }
- }
-
- result := &domusecase.PostPerformanceResult{
- AccountID: account.ID,
- Username: username,
- ThreadsUserID: userID,
- FetchedAt: time.Now().UnixNano(),
- Capabilities: postPerformanceCapabilities(),
- AccountInsights: domusecase.AccountInsightsBlock{
- Status: "skip",
- Message: "缺少 threads_user_id",
- },
- Posts: []domusecase.PostPerformanceItem{},
- }
-
- if userID != "" {
- result.AccountInsights = fetchAccountInsights(ctx, client, userID)
- posts, err := client.UserThreads(ctx, userID, limit)
- if err != nil {
- return nil, err
- }
- for _, post := range posts {
- item := domusecase.PostPerformanceItem{
- MediaID: post.ID,
- Text: post.Text,
- Permalink: post.Permalink,
- Timestamp: post.Timestamp,
- MediaType: post.MediaType,
- MediaURL: post.MediaURL,
- ThumbnailURL: post.ThumbnailURL,
- TopicTag: post.TopicTag,
- Shortcode: post.Shortcode,
- LikeCount: post.LikeCount,
- ReplyCount: post.ReplyCount,
- }
- if stats, serr := client.GetMediaStats(ctx, post.ID); serr == nil && stats != nil {
- item.LikeCount = stats.LikeCount
- item.ReplyCount = stats.ReplyCount
- item.RepostCount = stats.RepostCount
- item.QuoteCount = stats.QuoteCount
- item.Views = stats.Views
- item.Shares = stats.Shares
- item.InsightsStatus = "ok"
- } else {
- item.InsightsStatus = "error"
- if serr != nil {
- item.InsightsMessage = serr.Error()
- } else {
- item.InsightsMessage = "無法讀取貼文洞察"
- }
- }
- result.Posts = append(result.Posts, item)
- }
- }
-
- return result, nil
-}
-
-func postPerformanceCapabilities() []domusecase.PostMetricCapability {
- return []domusecase.PostMetricCapability{
- {
- Scope: "threads_manage_insights", Name: "貼文互動數",
- Metrics: []string{"likes", "replies", "reposts", "quotes", "views", "shares"},
- Trackable: true, Note: "GET /{media-id}/insights(非 media 物件欄位)",
- },
- {
- Scope: "threads_manage_insights", Name: "貼文洞察",
- Metrics: []string{"views", "likes", "replies", "reposts", "quotes", "shares"},
- Trackable: true, Note: "GET /{media-id}/insights(lifetime)",
- },
- {
- Scope: "threads_manage_insights", Name: "帳號洞察",
- Metrics: []string{"views", "likes", "replies", "reposts", "quotes", "followers_count", "clicks"},
- Trackable: true, Note: "GET /{user-id}/threads_insights",
- },
- {
- Scope: "threads_read_replies", Name: "留言內容",
- Metrics: []string{"reply_list"},
- Trackable: true, Note: "GET /{media-id}/replies(非數字統計)",
- },
- {
- Scope: "threads_keyword_search", Name: "關鍵字搜尋",
- Trackable: false, Note: "搜尋他人貼文,非你的發文成效",
- },
- {
- Scope: "threads_profile_discovery", Name: "帳號查詢",
- Trackable: false, Note: "查他人 profile,非成效統計",
- },
- {
- Scope: "threads_manage_mentions", Name: "提及",
- Trackable: false, Note: "誰 @ 你,非單篇貼文成效",
- },
- {
- Scope: "threads_location_tagging", Name: "地點",
- Trackable: false, Note: "地點標籤搜尋",
- },
- {
- Scope: "threads_content_publish", Name: "發文",
- Trackable: false, Note: "寫入類;發文後用本頁追蹤成效",
- },
- {
- Scope: "threads_delete", Name: "刪文",
- Trackable: false, Note: "寫入類",
- },
- {
- Scope: "threads_manage_replies", Name: "留言管理",
- Trackable: false, Note: "隱藏留言,非統計",
- },
- {
- Scope: "threads_share_to_instagram", Name: "同步 IG",
- Trackable: false, Note: "crosspost 動作",
- },
- }
-}
-
-func fetchAccountInsights(ctx context.Context, client *libthreads.Client, userID string) domusecase.AccountInsightsBlock {
- items, err := client.UserInsights(ctx, userID, nil)
- if err != nil {
- msg := err.Error()
- if libthreads.IsPermissionError(err) {
- msg += "(需 threads_manage_insights)"
- }
- return domusecase.AccountInsightsBlock{Status: "error", Message: msg}
- }
- metrics := make([]domusecase.InsightMetricItem, 0, len(items))
- for _, item := range items {
- name := strings.TrimSpace(item.Name)
- if name == "" || len(item.Values) == 0 {
- continue
- }
- metrics = append(metrics, domusecase.InsightMetricItem{Name: name, Value: item.Values[0].Value})
- }
- if len(metrics) == 0 {
- return domusecase.AccountInsightsBlock{Status: "ok", Message: "尚無帳號洞察資料"}
- }
- return domusecase.AccountInsightsBlock{
- Status: "ok",
- Message: fmt.Sprintf("%d 項指標", len(metrics)),
- Metrics: metrics,
- }
-}
diff --git a/old/backend/internal/model/threads_account/usecase/publish_credentials.go b/old/backend/internal/model/threads_account/usecase/publish_credentials.go
deleted file mode 100644
index 9f4e701..0000000
--- a/old/backend/internal/model/threads_account/usecase/publish_credentials.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-func (u *threadsAccountUseCase) ResolveMemberThreadsPublishCredential(
- ctx context.Context,
- tenantID, ownerUID string,
-) (*domusecase.ThreadsPublishCredential, error) {
- return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID)
-}
-
-func (u *threadsAccountUseCase) ResolveMemberThreadsReplyCredential(
- ctx context.Context,
- tenantID, ownerUID string,
-) (*domusecase.ThreadsPublishCredential, error) {
- return u.resolveMemberThreadsAPICredential(ctx, tenantID, ownerUID)
-}
-
-func (u *threadsAccountUseCase) resolveMemberThreadsAPICredential(
- ctx context.Context,
- tenantID, ownerUID string,
-) (*domusecase.ThreadsPublishCredential, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- member, err := u.members.GetByUID(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- activeID := strings.TrimSpace(member.ActiveThreadsAccountID)
- if activeID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先選定經營帳號")
- }
- account, err := u.assertOwned(ctx, tenantID, ownerUID, activeID)
- if err != nil {
- return nil, err
- }
- secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID)
- if err != nil {
- return nil, err
- }
- token := ""
- if secrets != nil {
- token = strings.TrimSpace(secrets.APIAccessToken)
- }
- userID := strings.TrimSpace(account.ThreadsUserID)
- apiConnected := token != "" && userID != ""
- if !apiConnected {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先完成 Threads API 連線後再發送留言")
- }
- return &domusecase.ThreadsPublishCredential{
- AccountID: account.ID,
- ThreadsUserID: userID,
- AccessToken: token,
- }, nil
-}
diff --git a/old/backend/internal/model/threads_account/usecase/token_refresh.go b/old/backend/internal/model/threads_account/usecase/token_refresh.go
deleted file mode 100644
index 1870126..0000000
--- a/old/backend/internal/model/threads_account/usecase/token_refresh.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package usecase
-
-import (
- "context"
- "strings"
- "time"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libthreads "haixun-backend/internal/library/threadsapi"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-const defaultTokenRefreshLeadTime = 7 * 24 * time.Hour
-
-func (u *threadsAccountUseCase) ResolveAccountAPIAccessToken(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (string, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return "", err
- }
- secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID)
- if err != nil {
- return "", err
- }
- if secrets == nil || !isValidAPIToken(secrets) {
- return "", app.For(code.ThreadsAccount).InputMissingRequired("此帳號尚未完成 Threads API 連線或 token 已過期")
- }
- return strings.TrimSpace(secrets.APIAccessToken), nil
-}
-
-func (u *threadsAccountUseCase) RefreshAccountAPIAccessToken(
- ctx context.Context,
- tenantID, ownerUID, accountID string,
-) (*domusecase.SecretsData, error) {
- token, err := u.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- result, err := libthreads.RefreshAccessToken(ctx, token)
- if err != nil {
- return nil, app.For(code.ThreadsAccount).SvcThirdParty("Threads token refresh 失敗").WithCause(err)
- }
- expiresAt := int64(0)
- if result.ExpiresIn > 0 {
- expiresAt = libthreads.NsFromNow(result.ExpiresIn)
- }
- return u.SaveAPIAccessToken(ctx, accountID, result.AccessToken, expiresAt)
-}
-
-func (u *threadsAccountUseCase) ListTokenRefreshCandidates(
- ctx context.Context,
- leadTime time.Duration,
-) ([]domusecase.TokenRefreshCandidate, error) {
- if leadTime <= 0 {
- leadTime = defaultTokenRefreshLeadTime
- }
- deadline := clock.NowUnixNano() + leadTime.Nanoseconds()
- secretsList, err := u.secretsRepo.ListAPIAccessTokensExpiringBefore(ctx, deadline)
- if err != nil {
- return nil, err
- }
- out := make([]domusecase.TokenRefreshCandidate, 0, len(secretsList))
- for _, secrets := range secretsList {
- if secrets == nil || strings.TrimSpace(secrets.APIAccessToken) == "" {
- continue
- }
- account, err := u.repo.FindByIDGlobal(ctx, secrets.AccountID)
- if err != nil || account == nil {
- continue
- }
- out = append(out, domusecase.TokenRefreshCandidate{
- AccountID: account.ID,
- TenantID: account.TenantID,
- OwnerUID: account.OwnerUID,
- ExpiresAt: secrets.APITokenExpiresAt,
- })
- }
- return out, nil
-}
diff --git a/old/backend/internal/model/threads_account/usecase/usecase.go b/old/backend/internal/model/threads_account/usecase/usecase.go
deleted file mode 100644
index 2c671bc..0000000
--- a/old/backend/internal/model/threads_account/usecase/usecase.go
+++ /dev/null
@@ -1,589 +0,0 @@
-package usecase
-
-import (
- "context"
- "encoding/json"
- "errors"
- "strings"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/library/crypto"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/placement"
- libthreads "haixun-backend/internal/library/threadsapi"
- memberdomain "haixun-backend/internal/model/member/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- analyticsrepo "haixun-backend/internal/model/publish_analytics/domain/repository"
- guardrepo "haixun-backend/internal/model/publish_guard/domain/repository"
- inventoryrepo "haixun-backend/internal/model/publish_inventory/domain/repository"
- queuerepo "haixun-backend/internal/model/publish_queue/domain/repository"
- eventrepo "haixun-backend/internal/model/publish_queue_event/domain/repository"
- settingdomain "haixun-backend/internal/model/setting/domain/usecase"
- "haixun-backend/internal/model/threads_account/domain/entity"
- domrepo "haixun-backend/internal/model/threads_account/domain/repository"
- domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
-
- "github.com/google/uuid"
-)
-
-const (
- settingScopeUser = "user"
- settingScopeAccount = "account"
- keyConnectionPrefs = "connection.prefs"
- defaultSearchSourceMode = "mixed"
- defaultRepliesPerPost = 10
-)
-
-type threadsAccountUseCase struct {
- repo domrepo.Repository
- secretsRepo domrepo.SecretsRepository
- oauthState domrepo.OAuthStateStore
- members memberdomain.UseCase
- settings settingdomain.UseCase
- personas personadomain.UseCase
- queueRepo queuerepo.Repository
- analytics analyticsrepo.Repository
- inventory inventoryrepo.Repository
- guard guardrepo.Repository
- queueEvents eventrepo.Repository
- cipher *crypto.Cipher
- oauth libthreads.OAuthConfig
-}
-
-func NewUseCase(
- repo domrepo.Repository,
- secretsRepo domrepo.SecretsRepository,
- oauthState domrepo.OAuthStateStore,
- members memberdomain.UseCase,
- settings settingdomain.UseCase,
- personas personadomain.UseCase,
- queueRepo queuerepo.Repository,
- analytics analyticsrepo.Repository,
- inventory inventoryrepo.Repository,
- guard guardrepo.Repository,
- queueEvents eventrepo.Repository,
- cipher *crypto.Cipher,
- oauth libthreads.OAuthConfig,
-) domusecase.UseCase {
- return &threadsAccountUseCase{
- repo: repo,
- secretsRepo: secretsRepo,
- oauthState: oauthState,
- members: members,
- settings: settings,
- personas: personas,
- queueRepo: queueRepo,
- analytics: analytics,
- inventory: inventory,
- guard: guard,
- queueEvents: queueEvents,
- cipher: cipher,
- oauth: oauth,
- }
-}
-
-func (u *threadsAccountUseCase) List(ctx context.Context, tenantID, ownerUID string) (*domusecase.ListResult, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- items, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- member, err := u.members.GetByUID(ctx, tenantID, ownerUID)
- if err != nil {
- return nil, err
- }
- activeID := member.ActiveThreadsAccountID
- if activeID == "" && len(items) > 0 {
- activeID = items[0].ID
- }
- list := make([]domusecase.AccountSummary, 0, len(items))
- for _, item := range items {
- summary, err := u.toSummary(ctx, item)
- if err != nil {
- return nil, err
- }
- list = append(list, *summary)
- }
- return &domusecase.ListResult{List: list, ActiveAccountID: activeID}, nil
-}
-
-func (u *threadsAccountUseCase) Create(ctx context.Context, req domusecase.CreateRequest) (*domusecase.AccountSummary, error) {
- if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
- return nil, err
- }
- displayName := strings.TrimSpace(req.DisplayName)
- if displayName == "" {
- existing, err := u.repo.ListByOwner(ctx, req.TenantID, req.OwnerUID)
- if err != nil {
- return nil, err
- }
- displayName = "帳號 " + itoa(len(existing)+1)
- }
- account, err := u.repo.Create(ctx, &entity.Account{
- ID: uuid.NewString(),
- TenantID: req.TenantID,
- OwnerUID: req.OwnerUID,
- DisplayName: displayName,
- Status: entity.StatusOpen,
- })
- if err != nil {
- return nil, err
- }
- if req.Activate {
- if err := u.members.SetActiveThreadsAccountID(ctx, req.TenantID, req.OwnerUID, account.ID); err != nil {
- return nil, err
- }
- }
- return u.toSummary(ctx, account)
-}
-
-func (u *threadsAccountUseCase) Get(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AccountSummary, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- return u.toSummary(ctx, account)
-}
-
-func (u *threadsAccountUseCase) Update(ctx context.Context, req domusecase.UpdateAccountRequest) (*domusecase.AccountSummary, error) {
- if _, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID); err != nil {
- return nil, err
- }
- var personaID *string
- if req.PersonaID != nil {
- trimmed := strings.TrimSpace(*req.PersonaID)
- if trimmed == "" {
- empty := ""
- personaID = &empty
- } else {
- if _, err := u.personas.Get(ctx, req.TenantID, req.OwnerUID, trimmed); err != nil {
- return nil, err
- }
- personaID = &trimmed
- }
- }
- account, err := u.repo.UpdateShell(ctx, req.TenantID, req.OwnerUID, req.AccountID, req.DisplayName, nil, personaID)
- if err != nil {
- return nil, err
- }
- return u.toSummary(ctx, account)
-}
-
-func (u *threadsAccountUseCase) Activate(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil {
- return err
- }
- return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, accountID)
-}
-
-func (u *threadsAccountUseCase) Delete(ctx context.Context, tenantID, ownerUID, accountID string) error {
- if _, err := u.assertOwned(ctx, tenantID, ownerUID, accountID); err != nil {
- return err
- }
- mediaIDs := []string{}
- if u.queueRepo != nil {
- if ids, err := u.queueRepo.ListMediaIDsByAccount(ctx, tenantID, ownerUID, accountID); err == nil {
- mediaIDs = ids
- }
- }
- if u.analytics != nil {
- _, _ = u.analytics.DeleteByMediaIDs(ctx, tenantID, ownerUID, mediaIDs)
- }
- if u.queueEvents != nil {
- _ = u.queueEvents.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
- }
- if u.queueRepo != nil {
- _, _ = u.queueRepo.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
- }
- if u.inventory != nil {
- _ = u.inventory.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
- }
- if u.guard != nil {
- _ = u.guard.DeleteByAccount(ctx, tenantID, ownerUID, accountID)
- }
- if u.secretsRepo != nil {
- _ = u.secretsRepo.DeleteByAccountID(ctx, accountID)
- }
- if u.settings != nil {
- settings, _, _, _, err := u.settings.List(ctx, settingScopeAccount, accountID, 1, 200)
- if err == nil {
- for _, item := range settings {
- if item == nil {
- continue
- }
- _ = u.settings.Delete(ctx, settingScopeAccount, accountID, item.Key)
- }
- }
- }
- if err := u.repo.Delete(ctx, tenantID, ownerUID, accountID); err != nil {
- return err
- }
- remaining, err := u.repo.ListByOwner(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- nextActiveID := ""
- if len(remaining) > 0 && remaining[0] != nil {
- nextActiveID = remaining[0].ID
- }
- return u.members.SetActiveThreadsAccountID(ctx, tenantID, ownerUID, nextActiveID)
-}
-
-func (u *threadsAccountUseCase) GetConnection(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.ConnectionData, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- prefs, err := u.loadConnectionPrefs(ctx, accountID)
- if err != nil {
- return nil, err
- }
- browserConnected, apiConnected, err := u.connectionFlags(ctx, accountID)
- if err != nil {
- return nil, err
- }
- return &domusecase.ConnectionData{
- AccountID: account.ID,
- AccountName: accountLabel(account),
- Username: account.Username,
- BrowserConnected: browserConnected,
- ApiConnected: apiConnected,
- Prefs: prefs,
- }, nil
-}
-
-func (u *threadsAccountUseCase) UpdateConnection(ctx context.Context, req domusecase.UpdateConnectionRequest) (*domusecase.ConnectionData, error) {
- account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID)
- if err != nil {
- return nil, err
- }
- current, err := u.loadConnectionPrefs(ctx, req.AccountID)
- if err != nil {
- return nil, err
- }
- next := applyConnectionPatch(current, req.Prefs)
- if err := u.saveConnectionPrefs(ctx, req.AccountID, next); err != nil {
- return nil, err
- }
- browserConnected, apiConnected, err := u.connectionFlags(ctx, req.AccountID)
- if err != nil {
- return nil, err
- }
- return &domusecase.ConnectionData{
- AccountID: account.ID,
- AccountName: accountLabel(account),
- Username: account.Username,
- BrowserConnected: browserConnected,
- ApiConnected: apiConnected,
- Prefs: next,
- }, nil
-}
-
-func (u *threadsAccountUseCase) ImportBrowserSession(ctx context.Context, req domusecase.ImportBrowserSessionRequest) (*domusecase.ImportBrowserSessionResult, error) {
- account, err := u.assertOwned(ctx, req.TenantID, req.OwnerUID, req.AccountID)
- if err != nil {
- return nil, err
- }
- normalized, err := normalizeStorageState(req.StorageState)
- if err != nil {
- return nil, err
- }
- secrets, err := u.secretsRepo.SaveBrowserStorageState(ctx, account.ID, normalized)
- if err != nil {
- return nil, err
- }
- message := "Chrome session 已同步到開發模式爬蟲"
- if account.Username != "" {
- message = "Chrome session 已同步:@" + account.Username
- }
- return &domusecase.ImportBrowserSessionResult{
- AccountID: account.ID,
- Username: account.Username,
- Synced: true,
- Valid: true,
- Message: message,
- UpdateAt: secrets.UpdateAt,
- }, nil
-}
-
-func (u *threadsAccountUseCase) GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.BrowserSessionData, error) {
- account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID)
- if err != nil {
- return nil, err
- }
- if secrets == nil || strings.TrimSpace(secrets.BrowserStorageState) == "" {
- return nil, app.For(code.ThreadsAccount).ResNotFound("browser session not synced")
- }
- return &domusecase.BrowserSessionData{
- AccountID: account.ID,
- StorageState: secrets.BrowserStorageState,
- UpdateAt: secrets.UpdateAt,
- }, nil
-}
-
-func (u *threadsAccountUseCase) assertOwned(ctx context.Context, tenantID, ownerUID, accountID string) (*entity.Account, error) {
- if err := requireActor(tenantID, ownerUID); err != nil {
- return nil, err
- }
- if strings.TrimSpace(accountID) == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account id is required")
- }
- return u.repo.FindByID(ctx, tenantID, ownerUID, accountID)
-}
-
-func (u *threadsAccountUseCase) toSummary(ctx context.Context, account *entity.Account) (*domusecase.AccountSummary, error) {
- browserConnected, apiConnected, err := u.connectionFlags(ctx, account.ID)
- if err != nil {
- return nil, err
- }
- apiTokenExpiresAt := int64(0)
- if secrets, err := u.secretsRepo.FindByAccountID(ctx, account.ID); err == nil && secrets != nil {
- apiTokenExpiresAt = secrets.APITokenExpiresAt
- }
- return &domusecase.AccountSummary{
- ID: account.ID,
- DisplayName: account.DisplayName,
- Username: account.Username,
- ThreadsUserID: account.ThreadsUserID,
- PersonaID: account.PersonaID,
- BrowserConnected: browserConnected,
- ApiConnected: apiConnected,
- APITokenExpiresAt: apiTokenExpiresAt,
- Status: string(account.Status),
- CreateAt: account.CreateAt,
- UpdateAt: account.UpdateAt,
- }, nil
-}
-
-func (u *threadsAccountUseCase) SaveAPIAccessToken(ctx context.Context, accountID, accessToken string, expiresAt int64) (*domusecase.SecretsData, error) {
- accountID = strings.TrimSpace(accountID)
- if accountID == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("account_id is required")
- }
- accessToken = strings.TrimSpace(accessToken)
- if accessToken == "" {
- return nil, app.For(code.ThreadsAccount).InputMissingRequired("access_token is required")
- }
- result, err := u.secretsRepo.SaveAPIAccessToken(ctx, accountID, accessToken, expiresAt)
- if err != nil {
- return nil, err
- }
- return &domusecase.SecretsData{
- AccountID: result.AccountID,
- APIAccessToken: result.APIAccessToken,
- APITokenExpiresAt: result.APITokenExpiresAt,
- UpdateAt: result.UpdateAt,
- }, nil
-}
-
-func (u *threadsAccountUseCase) connectionFlags(ctx context.Context, accountID string) (bool, bool, error) {
- secrets, err := u.secretsRepo.FindByAccountID(ctx, accountID)
- if err != nil {
- return false, false, err
- }
- if secrets == nil {
- return false, false, nil
- }
- browserConnected := strings.TrimSpace(secrets.BrowserStorageState) != ""
- apiConnected := strings.TrimSpace(secrets.APIAccessToken) != "" &&
- (secrets.APITokenExpiresAt == 0 || secrets.APITokenExpiresAt > clock.NowUnixNano())
- return browserConnected, apiConnected, nil
-}
-
-func (u *threadsAccountUseCase) loadConnectionPrefs(ctx context.Context, accountID string) (domusecase.ConnectionPrefs, error) {
- defaults := defaultConnectionPrefs()
- setting, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyConnectionPrefs)
- if err != nil {
- if isSettingNotFound(err) {
- return defaults, nil
- }
- return defaults, err
- }
- merged := mergeConnectionPrefs(defaults, setting.Value)
- return normalizeConnectionPrefs(merged), nil
-}
-
-func (u *threadsAccountUseCase) saveConnectionPrefs(ctx context.Context, accountID string, prefs domusecase.ConnectionPrefs) error {
- _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
- Scope: settingScopeAccount,
- ScopeID: accountID,
- Key: keyConnectionPrefs,
- Value: connectionPrefsToMap(prefs),
- })
- return err
-}
-
-func requireActor(tenantID, ownerUID string) error {
- if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
- return app.For(code.ThreadsAccount).InputMissingRequired("tenant_id and uid are required")
- }
- return nil
-}
-
-func accountLabel(account *entity.Account) string {
- if account == nil {
- return "未命名帳號"
- }
- if name := strings.TrimSpace(account.DisplayName); name != "" {
- return name
- }
- if name := strings.TrimSpace(account.Username); name != "" {
- return "@" + strings.TrimPrefix(name, "@")
- }
- return "未命名帳號"
-}
-
-func defaultConnectionPrefs() domusecase.ConnectionPrefs {
- return formalConnectionPrefs()
-}
-
-func formalConnectionPrefs() domusecase.ConnectionPrefs {
- return domusecase.ConnectionPrefs{
- DevMode: false,
- SearchViaApi: true,
- SearchSourceMode: string(placement.WithoutCrawler(placement.DefaultSearchSourceMode)),
- PublishViaApi: true,
- ScrapeReplies: false,
- RepliesPerPost: defaultRepliesPerPost,
- PublishHeaded: false,
- PlaywrightDebug: false,
- }
-}
-
-func applyConnectionPatch(current domusecase.ConnectionPrefs, patch domusecase.ConnectionPrefsPatch) domusecase.ConnectionPrefs {
- next := normalizeConnectionPrefs(current)
- if patch.ScrapeReplies != nil {
- next.ScrapeReplies = *patch.ScrapeReplies
- }
- if patch.RepliesPerPost != nil && *patch.RepliesPerPost > 0 {
- next.RepliesPerPost = *patch.RepliesPerPost
- }
- if patch.SearchSourceMode != nil {
- next.SearchSourceMode = strings.TrimSpace(*patch.SearchSourceMode)
- }
- return normalizeConnectionPrefs(next)
-}
-
-func normalizeConnectionPrefs(prefs domusecase.ConnectionPrefs) domusecase.ConnectionPrefs {
- out := formalConnectionPrefs()
- out.ScrapeReplies = prefs.ScrapeReplies
- if prefs.RepliesPerPost > 0 {
- out.RepliesPerPost = prefs.RepliesPerPost
- }
- mode := placement.ParseSearchSourceMode(prefs.SearchSourceMode)
- out.SearchSourceMode = string(placement.WithoutCrawler(mode))
- return out
-}
-
-func mergeConnectionPrefs(defaults domusecase.ConnectionPrefs, value map[string]interface{}) domusecase.ConnectionPrefs {
- if value == nil {
- return defaults
- }
- if v, ok := value["search_via_api"].(bool); ok {
- defaults.SearchViaApi = v
- }
- if v, ok := value["search_source_mode"].(string); ok && strings.TrimSpace(v) != "" {
- defaults.SearchSourceMode = v
- }
- if v, ok := value["publish_via_api"].(bool); ok {
- defaults.PublishViaApi = v
- }
- if v, ok := value["dev_mode"].(bool); ok {
- defaults.DevMode = v
- }
- if v, ok := value["scrape_replies"].(bool); ok {
- defaults.ScrapeReplies = v
- }
- if v, ok := asInt(value["replies_per_post"]); ok {
- defaults.RepliesPerPost = v
- }
- if v, ok := value["publish_headed"].(bool); ok {
- defaults.PublishHeaded = v
- }
- if v, ok := value["playwright_debug"].(bool); ok {
- defaults.PlaywrightDebug = v
- }
- return defaults
-}
-
-func connectionPrefsToMap(prefs domusecase.ConnectionPrefs) map[string]interface{} {
- return map[string]interface{}{
- "search_via_api": prefs.SearchViaApi,
- "search_source_mode": prefs.SearchSourceMode,
- "publish_via_api": prefs.PublishViaApi,
- "dev_mode": prefs.DevMode,
- "scrape_replies": prefs.ScrapeReplies,
- "replies_per_post": prefs.RepliesPerPost,
- "publish_headed": prefs.PublishHeaded,
- "playwright_debug": prefs.PlaywrightDebug,
- }
-}
-
-type playwrightStorageState struct {
- Cookies []interface{} `json:"cookies"`
- Origins []interface{} `json:"origins,omitempty"`
-}
-
-func normalizeStorageState(input string) (string, error) {
- trimmed := strings.TrimSpace(input)
- if trimmed == "" {
- return "", app.For(code.ThreadsAccount).InputMissingRequired("storageState is required")
- }
- var parsed playwrightStorageState
- if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
- return "", app.For(code.ThreadsAccount).InputInvalidFormat("storageState is not valid JSON")
- }
- if parsed.Cookies == nil {
- return "", app.For(code.ThreadsAccount).InputInvalidFormat("storageState must include cookies array")
- }
- raw, err := json.Marshal(parsed)
- if err != nil {
- return "", err
- }
- return string(raw), nil
-}
-
-func isSettingNotFound(err error) bool {
- var appErr *app.Error
- if errors.As(err, &appErr) {
- return appErr.Category() == code.ResNotFound
- }
- return false
-}
-
-func asInt(v interface{}) (int, bool) {
- switch n := v.(type) {
- case int:
- return n, true
- case int32:
- return int(n), true
- case int64:
- return int(n), true
- case float64:
- return int(n), true
- default:
- return 0, false
- }
-}
-
-func itoa(n int) string {
- if n <= 0 {
- return "1"
- }
- buf := make([]byte, 0, 12)
- for n > 0 {
- buf = append(buf, byte('0'+n%10))
- n /= 10
- }
- for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
- buf[i], buf[j] = buf[j], buf[i]
- }
- return string(buf)
-}
diff --git a/old/backend/internal/response/request.go b/old/backend/internal/response/request.go
deleted file mode 100644
index 4ff241d..0000000
--- a/old/backend/internal/response/request.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package response
-
-import (
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/redact"
-)
-
-func WrapRequestError(err error) error {
- if err == nil {
- return nil
- }
- return app.For(code.Facade).InputInvalidFormat(redact.Message(err.Error()))
-}
diff --git a/old/backend/internal/response/response.go b/old/backend/internal/response/response.go
deleted file mode 100644
index 51bf648..0000000
--- a/old/backend/internal/response/response.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package response
-
-import (
- "context"
- "net/http"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/types"
-
- "github.com/zeromicro/go-zero/rest/httpx"
-)
-
-func Write(ctx context.Context, w http.ResponseWriter, data any, err error) {
- if err != nil {
- status, body := buildError(err)
- httpx.WriteJsonCtx(ctx, w, status, body)
- return
- }
-
- httpx.WriteJsonCtx(ctx, w, http.StatusOK, types.Status{
- Code: code.DefaultSuccessFullCodeInt,
- Message: code.DefaultSuccessMessage,
- Data: data,
- })
-}
-
-func buildError(err error) (int, types.Status) {
- if appErr := app.FromError(err); appErr != nil {
- return appErr.HTTPStatus(), types.Status{
- Code: int64(appErr.Code()),
- Message: appErr.Error(),
- Error: types.ErrorDetail{
- BizCode: appErr.DisplayCode(),
- Scope: int64(appErr.Scope()),
- Category: int64(appErr.Category()),
- Detail: int64(appErr.Detail()),
- },
- }
- }
-
- fallback := app.For(code.Unset).SysInternal("internal server error")
- return http.StatusInternalServerError, types.Status{
- Code: int64(fallback.Code()),
- Message: fallback.Error(),
- Error: types.ErrorDetail{
- BizCode: fallback.DisplayCode(),
- Scope: int64(fallback.Scope()),
- Category: int64(fallback.Category()),
- Detail: int64(fallback.Detail()),
- },
- }
-}
diff --git a/old/backend/internal/svc/job_workers.go b/old/backend/internal/svc/job_workers.go
deleted file mode 100644
index f0e6e2d..0000000
--- a/old/backend/internal/svc/job_workers.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package svc
-
-import (
- "context"
- "fmt"
- "log"
-
- jobworker "haixun-backend/internal/worker/job"
-)
-
-var inlineCopyWorkerTypes = []string{
- "go-generate-topic-matrix",
- "go-generate-formula-draft",
- "go-generate-rewrite-draft",
-}
-
-func registerCopyJobHandlers(runner *jobworker.Runner, sc *ServiceContext) {
- if runner == nil || sc == nil {
- return
- }
- jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{
- Jobs: sc.Job,
- Persona: sc.Persona,
- CopyDraft: sc.CopyDraft,
- ContentOps: sc.ContentOps,
- ThreadsAccount: sc.ThreadsAccount,
- Placement: sc.Placement,
- AI: sc.AI,
- })
- jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{
- Jobs: sc.Job,
- Persona: sc.Persona,
- ContentFormula: sc.ContentFormula,
- CopyDraft: sc.CopyDraft,
- ThreadsAccount: sc.ThreadsAccount,
- Placement: sc.Placement,
- AI: sc.AI,
- })
- jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{
- Jobs: sc.Job,
- Persona: sc.Persona,
- ContentFormula: sc.ContentFormula,
- CopyDraft: sc.CopyDraft,
- ThreadsAccount: sc.ThreadsAccount,
- Placement: sc.Placement,
- AI: sc.AI,
- })
-}
-
-func (sc *ServiceContext) startInlineCopyWorkers(ctx context.Context, hostname string) {
- if sc == nil || sc.Redis == nil {
- return
- }
- for _, workerType := range inlineCopyWorkerTypes {
- workerType := workerType
- workerID := fmt.Sprintf("%s-inline-%s", hostname, workerType)
- runner := jobworker.NewRunner(workerID, workerType, sc.Job)
- registerCopyJobHandlers(runner, sc)
- go runner.Start(ctx)
- log.Printf("inline copy job worker started: id=%s type=%s", workerID, workerType)
- }
-}
diff --git a/old/backend/internal/svc/service_context.go b/old/backend/internal/svc/service_context.go
deleted file mode 100644
index d7de766..0000000
--- a/old/backend/internal/svc/service_context.go
+++ /dev/null
@@ -1,640 +0,0 @@
-package svc
-
-import (
- "context"
- "fmt"
- "net/http"
- "os"
- "time"
-
- "haixun-backend/internal/config"
- libcrypto "haixun-backend/internal/library/crypto"
- "haixun-backend/internal/library/mailer"
- libmongo "haixun-backend/internal/library/mongo"
- libredis "haixun-backend/internal/library/redis"
- "haixun-backend/internal/library/storage"
- libthreads "haixun-backend/internal/library/threadsapi"
- "haixun-backend/internal/library/validate"
- "haixun-backend/internal/middleware"
- aisettings "haixun-backend/internal/model/ai/usecase"
- authrepodomain "haixun-backend/internal/model/auth/domain/repository"
- authdomain "haixun-backend/internal/model/auth/domain/usecase"
- authrepo "haixun-backend/internal/model/auth/repository"
- authuc "haixun-backend/internal/model/auth/usecase"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- brandrepo "haixun-backend/internal/model/brand/repository"
- brandusecase "haixun-backend/internal/model/brand/usecase"
- contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
- contentformularepo "haixun-backend/internal/model/content_formula/repository"
- contentformulausecase "haixun-backend/internal/model/content_formula/usecase"
- cmatrixdomain "haixun-backend/internal/model/content_matrix/domain/usecase"
- cmatrixrepo "haixun-backend/internal/model/content_matrix/repository"
- cmatrixusecase "haixun-backend/internal/model/content_matrix/usecase"
- contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
- contentopsrepo "haixun-backend/internal/model/content_ops/repository"
- contentopsusecase "haixun-backend/internal/model/content_ops/usecase"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- copydraftrepo "haixun-backend/internal/model/copy_draft/repository"
- copydraftusecase "haixun-backend/internal/model/copy_draft/usecase"
- copymissiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- copymissionrepo "haixun-backend/internal/model/copy_mission/repository"
- copymissionusecase "haixun-backend/internal/model/copy_mission/usecase"
- jobrepo "haixun-backend/internal/model/job/repository"
- jobusecase "haixun-backend/internal/model/job/usecase"
- kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- kgrepo "haixun-backend/internal/model/knowledge_graph/repository"
- kgusecase "haixun-backend/internal/model/knowledge_graph/usecase"
- memberdomain "haixun-backend/internal/model/member/domain/usecase"
- memberrepo "haixun-backend/internal/model/member/repository"
- memberuc "haixun-backend/internal/model/member/usecase"
- mentioninboxdomain "haixun-backend/internal/model/mention_inbox/domain/usecase"
- mentioninboxrepo "haixun-backend/internal/model/mention_inbox/repository"
- mentioninboxusecase "haixun-backend/internal/model/mention_inbox/usecase"
- outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase"
- outreachdraftrepo "haixun-backend/internal/model/outreach_draft/repository"
- outreachdraftusecase "haixun-backend/internal/model/outreach_draft/usecase"
- ownpostformularepo "haixun-backend/internal/model/own_post_formula/repository"
- ownpostformuladomain "haixun-backend/internal/model/own_post_formula/usecase"
- permissiondomain "haixun-backend/internal/model/permission/domain/usecase"
- permissionrepo "haixun-backend/internal/model/permission/repository"
- permissionuc "haixun-backend/internal/model/permission/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- personarepo "haixun-backend/internal/model/persona/repository"
- personausecase "haixun-backend/internal/model/persona/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
- placementtopicrepo "haixun-backend/internal/model/placement_topic/repository"
- placementtopicusecase "haixun-backend/internal/model/placement_topic/usecase"
- publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
- publishanalyticsrepo "haixun-backend/internal/model/publish_analytics/repository"
- publishanalyticsusecase "haixun-backend/internal/model/publish_analytics/usecase"
- publishguarddomain "haixun-backend/internal/model/publish_guard/domain/usecase"
- publishguardrepo "haixun-backend/internal/model/publish_guard/repository"
- publishguardusecase "haixun-backend/internal/model/publish_guard/usecase"
- publishinventorydomain "haixun-backend/internal/model/publish_inventory/domain/usecase"
- publishinventoryrepo "haixun-backend/internal/model/publish_inventory/repository"
- publishinventoryusecase "haixun-backend/internal/model/publish_inventory/usecase"
- publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase"
- publishqueuerepo "haixun-backend/internal/model/publish_queue/repository"
- publishqueueusecase "haixun-backend/internal/model/publish_queue/usecase"
- publishqueueeventdomain "haixun-backend/internal/model/publish_queue_event/domain/usecase"
- publishqueueeventrepo "haixun-backend/internal/model/publish_queue_event/repository"
- publishqueueeventusecase "haixun-backend/internal/model/publish_queue_event/usecase"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
- scanpostrepo "haixun-backend/internal/model/scan_post/repository"
- scanpostusecase "haixun-backend/internal/model/scan_post/usecase"
- settingrepo "haixun-backend/internal/model/setting/repository"
- settingusecase "haixun-backend/internal/model/setting/usecase"
- stylepresetdomain "haixun-backend/internal/model/style_preset/domain/usecase"
- stylepresetrepo "haixun-backend/internal/model/style_preset/repository"
- stylepresetusecase "haixun-backend/internal/model/style_preset/usecase"
- threadsaccountrepodomain "haixun-backend/internal/model/threads_account/domain/repository"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
- threadsaccountrepo "haixun-backend/internal/model/threads_account/repository"
- threadsaccountusecase "haixun-backend/internal/model/threads_account/usecase"
- jobworker "haixun-backend/internal/worker/job"
-
- goredis "github.com/redis/go-redis/v9"
- "github.com/zeromicro/go-zero/rest"
-)
-
-type ServiceContext struct {
- Config config.Config
- Validator *validate.Validate
- Mongo *libmongo.Client
- Redis *goredis.Client
-
- Setting settingusecase.UseCase
- AI aisettings.UseCase
- Job jobusecase.UseCase
- AuthToken authdomain.TokenUseCase
- Member memberdomain.UseCase
- Permission permissiondomain.UseCase
- Persona personadomain.UseCase
- CopyMission copymissiondomain.UseCase
- Brand branddomain.UseCase
- PlacementTopic placementtopicdomain.UseCase
- KnowledgeGraph kgdomain.UseCase
- Placement placementusecase.UseCase
- ScanPost scanpostdomain.UseCase
- OutreachDraft outreachdraftdomain.UseCase
- ContentMatrix cmatrixdomain.UseCase
- ContentOps contentopsdomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- PublishAnalytics publishanalyticsdomain.UseCase
- PublishQueue publishqueuedomain.UseCase
- PublishInventory publishinventorydomain.UseCase
- PublishGuard publishguarddomain.UseCase
- PublishQueueEvent publishqueueeventdomain.UseCase
- StylePreset stylepresetdomain.UseCase
- OwnPostFormula ownpostformuladomain.UseCase
- ContentFormula contentformuladomain.UseCase
- MentionInbox mentioninboxdomain.UseCase
- AvatarStorage storage.Uploader
- Mailer mailer.Mailer
-
- // Middlewares mounted per route group via generate/api `middleware:` directive.
- AuthJWT rest.Middleware
- MemberAuth rest.Middleware
- WorkerSecret rest.Middleware
-
- stopWorker context.CancelFunc
- stopScheduler context.CancelFunc
- stopReaper context.CancelFunc
- stopThreadsTokenSweep context.CancelFunc
- stopPublishQueueSweep context.CancelFunc
-}
-
-func NewServiceContext(c config.Config) *ServiceContext {
- ctx := context.Background()
- mongoClient, err := libmongo.NewClient(ctx, c.Mongo)
- if err != nil {
- panic(err)
- }
- redisClient := libredis.NewClient(c.Redis)
-
- secretsCipher, err := libcrypto.New(c.Secrets.EncryptionKey)
- if err != nil {
- panic(err)
- }
-
- settingRepository := settingrepo.NewMongoRepository(mongoClient.Database())
- if err := settingRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- settingUseCase := settingusecase.NewUseCase(settingRepository)
-
- var tokenRevokeStore authrepodomain.TokenRevokeStore
- if redisClient != nil {
- tokenRevokeStore = authrepo.NewRedisTokenRevokeStore(redisClient)
- }
- authTokenUseCase := authuc.NewTokenUseCase(c.Auth, tokenRevokeStore)
- avatarStorage, err := storage.NewS3Uploader(ctx, c.Storage.S3)
- if err != nil {
- panic(err)
- }
- smtpMailer := mailer.NewSMTPMailer(c.SMTP)
-
- memberRepository := memberrepo.NewMongoRepository(mongoClient.Database())
- if err := memberRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- memberUseCase := memberuc.NewUseCase(memberRepository, authTokenUseCase)
-
- permissionRepository := permissionrepo.NewMongoPermissionRepository(mongoClient.Database())
- rolePermissionRepository := permissionrepo.NewMongoRolePermissionRepository(mongoClient.Database())
- if err := permissionRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := rolePermissionRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
- if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
- panic(err)
- }
- if c.Permission.SeedAdminRegister {
- if err := permissionUseCase.EnsureAdminRegisterPermission(ctx); err != nil {
- panic(err)
- }
- }
- if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, "default"); err != nil {
- panic(err)
- }
-
- jobTemplateRepository := jobrepo.NewMongoTemplateRepository(mongoClient.Database())
- jobRunRepository := jobrepo.NewMongoRunRepository(mongoClient.Database())
- jobScheduleRepository := jobrepo.NewMongoScheduleRepository(mongoClient.Database())
- jobEventRepository := jobrepo.NewMongoEventRepository(mongoClient.Database())
- jobQueueRepository := jobrepo.NewRedisQueueRepository(redisClient)
- if err := jobTemplateRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := jobRunRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := jobScheduleRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := jobEventRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
-
- jobUseCase := jobusecase.NewUseCase(jobTemplateRepository, jobRunRepository, jobScheduleRepository, jobEventRepository, jobQueueRepository)
- if err := jobUseCase.EnsureDemoTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureStyle8DTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureExpandGraphTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsurePlacementScanTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureScanViralTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureGenerateOutreachDraftTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureGenerateTopicMatrixTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureGenerateFormulaDraftTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureGenerateRewriteDraftTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsureRefreshThreadsTokenTemplate(ctx); err != nil {
- panic(err)
- }
- if err := jobUseCase.EnsurePublishAnalyticsTemplate(ctx); err != nil {
- panic(err)
- }
- copyMissionRepository := copymissionrepo.NewMongoRepository(mongoClient.Database())
- if err := copyMissionRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- copyMissionUseCase := copymissionusecase.NewUseCase(copyMissionRepository)
-
- copyDraftRepository := copydraftrepo.NewMongoRepository(mongoClient.Database())
- if err := copyDraftRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- copyDraftUseCase := copydraftusecase.NewUseCase(copyDraftRepository, redisClient)
-
- scanPostRepository := scanpostrepo.NewMongoRepository(mongoClient.Database())
- if err := scanPostRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- scanPostUseCase := scanpostusecase.NewUseCase(scanPostRepository)
-
- outreachDraftRepository := outreachdraftrepo.NewMongoRepository(mongoClient.Database())
- if err := outreachDraftRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- outreachDraftUseCase := outreachdraftusecase.NewUseCase(outreachDraftRepository)
-
- contentMatrixRepository := cmatrixrepo.NewMongoRepository(mongoClient.Database())
- if err := contentMatrixRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- contentMatrixUseCase := cmatrixusecase.NewUseCase(contentMatrixRepository)
-
- knowledgeGraphRepository := kgrepo.NewMongoRepository(mongoClient.Database())
- if err := knowledgeGraphRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- knowledgeGraphUseCase := kgusecase.NewUseCase(knowledgeGraphRepository)
-
- personaRepository := personarepo.NewMongoRepository(mongoClient.Database())
- if err := personaRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- personaUseCase := personausecase.NewUseCase(personaRepository)
-
- brandRepository := brandrepo.NewMongoRepository(mongoClient.Database())
- if err := brandRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- brandUseCase := brandusecase.NewUseCase(brandRepository)
- placementTopicRepository := placementtopicrepo.NewMongoRepository(mongoClient.Database())
- if err := placementTopicRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- placementTopicUseCase := placementtopicusecase.NewUseCase(
- placementTopicRepository,
- brandRepository,
- knowledgeGraphUseCase,
- scanPostRepository,
- )
- threadsAccountRepository := threadsaccountrepo.NewMongoRepository(mongoClient.Database())
- threadsAccountSecretsRepository := threadsaccountrepo.NewSecretsMongoRepository(mongoClient.Database(), secretsCipher)
- publishAnalyticsRepository := publishanalyticsrepo.NewMongoRepository(mongoClient.Database())
- publishQueueRepository := publishqueuerepo.NewMongoRepository(mongoClient.Database())
- publishInventoryRepository := publishinventoryrepo.NewMongoRepository(mongoClient.Database())
- publishGuardRepository := publishguardrepo.NewMongoRepository(mongoClient.Database())
- publishQueueEventRepository := publishqueueeventrepo.NewMongoRepository(mongoClient.Database())
- stylePresetRepository := stylepresetrepo.NewMongoRepository(mongoClient.Database())
- ownPostFormulaRepository := ownpostformularepo.NewMongoRepository(mongoClient.Database())
- contentFormulaRepository := contentformularepo.NewMongoRepository(mongoClient.Database())
- contentOpsRepository := contentopsrepo.NewMongoRepository(mongoClient.Database())
- mentionInboxRepository := mentioninboxrepo.NewMongoRepository(mongoClient.Database())
- var threadsOAuthStateStore threadsaccountrepodomain.OAuthStateStore
- if redisClient != nil {
- threadsOAuthStateStore = threadsaccountrepo.NewOAuthStateRedisStore(redisClient)
- }
- threadsOAuthConfig := libthreads.OAuthConfig{
- AppID: c.ThreadsOAuth.AppID,
- AppSecret: c.ThreadsOAuth.AppSecret,
- RedirectURI: c.ThreadsOAuth.RedirectURI,
- SuccessRedirect: c.ThreadsOAuth.SuccessRedirect,
- }
- if err := threadsAccountRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := threadsAccountSecretsRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := publishAnalyticsRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := publishQueueRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := publishInventoryRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := publishGuardRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := publishQueueEventRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := stylePresetRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := ownPostFormulaRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := contentFormulaRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := contentOpsRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- if err := mentionInboxRepository.EnsureIndexes(ctx); err != nil {
- panic(err)
- }
- ownPostFormulaUseCase := ownpostformuladomain.NewUseCase(ownPostFormulaRepository)
- contentFormulaUseCase := contentformulausecase.NewUseCase(contentFormulaRepository)
- contentOpsUseCase := contentopsusecase.NewUseCase(contentOpsRepository)
- threadsAccountUseCase := threadsaccountusecase.NewUseCase(
- threadsAccountRepository,
- threadsAccountSecretsRepository,
- threadsOAuthStateStore,
- memberUseCase,
- settingUseCase,
- personaUseCase,
- publishQueueRepository,
- publishAnalyticsRepository,
- publishInventoryRepository,
- publishGuardRepository,
- publishQueueEventRepository,
- secretsCipher,
- threadsOAuthConfig,
- )
- mentionInboxUseCase := mentioninboxusecase.NewUseCase(mentionInboxRepository, threadsAccountUseCase)
-
- placementUseCase := placementusecase.NewUseCase(settingUseCase, secretsCipher)
-
- publishAnalyticsUseCase := publishanalyticsusecase.NewUseCase(publishAnalyticsRepository, jobUseCase)
- publishQueueEventUseCase := publishqueueeventusecase.NewUseCase(publishQueueEventRepository)
- publishInventoryUseCase := publishinventoryusecase.NewUseCase(publishInventoryRepository, threadsAccountUseCase)
- publishGuardUseCase := publishguardusecase.NewUseCase(publishGuardRepository, threadsAccountUseCase)
- stylePresetUseCase := stylepresetusecase.NewUseCase(stylePresetRepository, personaUseCase)
-
- var publishAttachmentStore storage.PublishAttachmentStore
- if avatarStorage != nil {
- publishAttachmentStore = avatarStorage
- }
- publishQueueUseCase := publishqueueusecase.NewUseCase(
- publishQueueRepository,
- threadsAccountUseCase,
- publishAnalyticsUseCase,
- publishGuardUseCase,
- publishQueueEventUseCase,
- copyDraftUseCase,
- publishAttachmentStore,
- )
-
- sc := &ServiceContext{
- Config: c,
- Validator: validate.New(),
- Mongo: mongoClient,
- Redis: redisClient,
- Setting: settingUseCase,
- AI: aisettings.NewUseCase(),
- Job: jobUseCase,
- AuthToken: authTokenUseCase,
- Member: memberUseCase,
- Permission: permissionUseCase,
- Persona: personaUseCase,
- CopyMission: copyMissionUseCase,
- Brand: brandUseCase,
- PlacementTopic: placementTopicUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
- Placement: placementUseCase,
- ScanPost: scanPostUseCase,
- OutreachDraft: outreachDraftUseCase,
- ContentMatrix: contentMatrixUseCase,
- ContentOps: contentOpsUseCase,
- CopyDraft: copyDraftUseCase,
- ThreadsAccount: threadsAccountUseCase,
- PublishAnalytics: publishAnalyticsUseCase,
- PublishQueue: publishQueueUseCase,
- PublishInventory: publishInventoryUseCase,
- PublishGuard: publishGuardUseCase,
- PublishQueueEvent: publishQueueEventUseCase,
- StylePreset: stylePresetUseCase,
- OwnPostFormula: ownPostFormulaUseCase,
- ContentFormula: contentFormulaUseCase,
- MentionInbox: mentionInboxUseCase,
- AvatarStorage: avatarStorage,
- Mailer: smtpMailer,
- }
-
- hostname, _ := os.Hostname()
- if c.JobWorker.Enabled && redisClient != nil {
- workerType := c.JobWorker.WorkerType
- if workerType == "" {
- workerType = "go"
- }
- workerID := c.JobWorker.WorkerID
- if workerID == "" {
- workerID = fmt.Sprintf("%s-%s-worker", hostname, workerType)
- }
- workerCtx, cancel := context.WithCancel(context.Background())
- sc.stopWorker = cancel
- runner := jobworker.NewRunner(workerID, workerType, jobUseCase)
- jobworker.RegisterExpandGraphHandler(runner, jobworker.ExpandGraphDeps{
- Jobs: jobUseCase,
- Brand: brandUseCase,
- PlacementTopic: placementTopicUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
- ThreadsAccount: threadsAccountUseCase,
- Placement: placementUseCase,
- AI: sc.AI,
- })
- jobworker.RegisterScanPlacementHandler(runner, jobworker.ScanPlacementDeps{
- Jobs: jobUseCase,
- Brand: brandUseCase,
- PlacementTopic: placementTopicUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
- ScanPost: scanPostUseCase,
- ThreadsAccount: threadsAccountUseCase,
- Placement: placementUseCase,
- })
- jobworker.RegisterScanViralHandler(runner, jobworker.ScanViralDeps{
- Jobs: jobUseCase,
- CopyMission: copyMissionUseCase,
- Persona: personaUseCase,
- ScanPost: scanPostUseCase,
- CopyDraft: copyDraftUseCase,
- ThreadsAccount: threadsAccountUseCase,
- Placement: placementUseCase,
- AI: sc.AI,
- })
- jobworker.RegisterGenerateOutreachDraftHandler(runner, jobworker.GenerateOutreachDraftDeps{
- Jobs: jobUseCase,
- Brand: brandUseCase,
- Persona: personaUseCase,
- ScanPost: scanPostUseCase,
- KnowledgeGraph: knowledgeGraphUseCase,
- ThreadsAccount: threadsAccountUseCase,
- AI: sc.AI,
- OutreachDraft: outreachDraftUseCase,
- })
- jobworker.RegisterGenerateTopicMatrixHandler(runner, jobworker.GenerateTopicMatrixDeps{
- Jobs: jobUseCase,
- Persona: personaUseCase,
- CopyDraft: copyDraftUseCase,
- ContentOps: contentOpsUseCase,
- ThreadsAccount: threadsAccountUseCase,
- Placement: placementUseCase,
- AI: sc.AI,
- })
- jobworker.RegisterGenerateFormulaDraftHandler(runner, jobworker.GenerateFormulaDraftDeps{
- Jobs: jobUseCase,
- Persona: personaUseCase,
- ContentFormula: contentFormulaUseCase,
- CopyDraft: copyDraftUseCase,
- ThreadsAccount: threadsAccountUseCase,
- Placement: placementUseCase,
- AI: sc.AI,
- })
- jobworker.RegisterGenerateRewriteDraftHandler(runner, jobworker.GenerateRewriteDraftDeps{
- Jobs: jobUseCase,
- Persona: personaUseCase,
- ContentFormula: contentFormulaUseCase,
- CopyDraft: copyDraftUseCase,
- ThreadsAccount: threadsAccountUseCase,
- Placement: placementUseCase,
- AI: sc.AI,
- })
- jobworker.RegisterRefreshThreadsTokenHandler(runner, jobworker.RefreshThreadsTokenDeps{
- Jobs: jobUseCase,
- ThreadsAccount: threadsAccountUseCase,
- })
- jobworker.RegisterPublishAnalyticsHandler(runner, jobworker.PublishAnalyticsDeps{
- Jobs: jobUseCase,
- ThreadsAccount: threadsAccountUseCase,
- PublishAnalytics: publishAnalyticsUseCase,
- })
- go runner.Start(workerCtx)
- } else if redisClient != nil {
- inlineCtx, cancel := context.WithCancel(context.Background())
- sc.stopWorker = cancel
- sc.startInlineCopyWorkers(inlineCtx, hostname)
- }
-
- if c.JobScheduler.Enabled && redisClient != nil {
- schedulerHolder := fmt.Sprintf("%s-scheduler", hostname)
- interval := time.Duration(c.JobScheduler.IntervalSeconds) * time.Second
- if interval <= 0 {
- interval = time.Minute
- }
- schedulerCtx, cancel := context.WithCancel(context.Background())
- sc.stopScheduler = cancel
- scheduler := jobworker.NewScheduler(schedulerHolder, jobUseCase)
- go scheduler.Start(schedulerCtx, interval)
- }
-
- if c.ThreadsTokenRefresh.Enabled && redisClient != nil {
- leadDays := c.ThreadsTokenRefresh.LeadDays
- if leadDays <= 0 {
- leadDays = 7
- }
- interval := time.Duration(c.ThreadsTokenRefresh.IntervalSeconds) * time.Second
- if interval <= 0 {
- interval = time.Hour
- }
- sweepCtx, cancel := context.WithCancel(context.Background())
- sc.stopThreadsTokenSweep = cancel
- sweeper := jobworker.NewThreadsTokenSweeper(
- threadsAccountUseCase,
- jobUseCase,
- time.Duration(leadDays)*24*time.Hour,
- )
- go sweeper.Start(sweepCtx, interval)
- }
-
- pqEnabled := c.ThreadsPublishQueue.Enabled
- if !pqEnabled && c.ThreadsPublishQueue.IntervalSeconds == 0 && c.ThreadsPublishQueue.BatchSize == 0 {
- pqEnabled = true
- }
- if pqEnabled {
- interval := time.Duration(c.ThreadsPublishQueue.IntervalSeconds) * time.Second
- if interval <= 0 {
- interval = time.Minute
- }
- batchSize := c.ThreadsPublishQueue.BatchSize
- if batchSize <= 0 {
- batchSize = 20
- }
- pqCtx, cancel := context.WithCancel(context.Background())
- sc.stopPublishQueueSweep = cancel
- pqSweeper := jobworker.NewPublishQueueSweeper(publishQueueUseCase, batchSize)
- go pqSweeper.Start(pqCtx, interval)
- }
-
- if c.JobReaper.Enabled && redisClient != nil {
- interval := time.Duration(c.JobReaper.IntervalSeconds) * time.Second
- if interval <= 0 {
- interval = 30 * time.Second
- }
- reaperCtx, cancel := context.WithCancel(context.Background())
- sc.stopReaper = cancel
- reaper := jobworker.NewReaper(jobUseCase)
- go reaper.Start(reaperCtx, interval)
- }
-
- // 認證 + RBAC:先驗 JWT(把 actor 放進 context),再用 catalog 權限做路由級授權。
- // 兩者組合後掛在受保護的 route group 上(routes.go 以 AuthJWT / MemberAuth 引用)。
- authJWT := middleware.NewAuthJWTMiddleware(sc.AuthToken, sc.Config.Auth).Handle
- memberAuth := middleware.NewMemberAuthMiddleware(sc.AuthToken, sc.Config.Auth).Handle
- emailVerified := middleware.NewEmailVerifiedMiddleware(sc.Member).Handle
- rbac := middleware.NewPermissionRBACMiddleware(sc.Member, sc.Permission).Handle
-
- sc.AuthJWT = func(next http.HandlerFunc) http.HandlerFunc { return authJWT(emailVerified(rbac(next))) }
- sc.MemberAuth = func(next http.HandlerFunc) http.HandlerFunc { return memberAuth(emailVerified(rbac(next))) }
- sc.WorkerSecret = middleware.NewWorkerSecretMiddleware(sc.Config.InternalWorker).Handle
-
- return sc
-}
-
-func (sc *ServiceContext) Close(ctx context.Context) {
- if sc == nil {
- return
- }
- if sc.stopWorker != nil {
- sc.stopWorker()
- }
- if sc.stopScheduler != nil {
- sc.stopScheduler()
- }
- if sc.stopReaper != nil {
- sc.stopReaper()
- }
- if sc.stopThreadsTokenSweep != nil {
- sc.stopThreadsTokenSweep()
- }
- _ = sc.Mongo.Close(ctx)
- if sc.Redis != nil {
- _ = sc.Redis.Close()
- }
-}
diff --git a/old/backend/internal/types/types.go b/old/backend/internal/types/types.go
deleted file mode 100644
index 395006a..0000000
--- a/old/backend/internal/types/types.go
+++ /dev/null
@@ -1,2961 +0,0 @@
-// Code generated by goctl. DO NOT EDIT.
-// goctl 1.10.1
-
-package types
-
-type AIChatData struct {
- Text string `json:"text"`
- FinishReason string `json:"finish_reason,optional"`
-}
-
-type AIChatReq struct {
- Provider string `json:"provider" validate:"required,oneof=opencode-go xai"` // AI provider
- Model string `json:"model" validate:"required"` // 模型 ID
- System string `json:"system,optional"` // system prompt
- Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
- Temperature *float64 `json:"temperature,optional"` // 溫度
- MaxTokens *int `json:"max_tokens,optional"` // 最大輸出 token
-}
-
-type AIMessage struct {
- Role string `json:"role" validate:"required,oneof=system user assistant"` // 訊息角色
- Content string `json:"content" validate:"required"` // 訊息內容
-}
-
-type AIProviderModelsData struct {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,optional"` // 拉 models 失敗時的摘要
-}
-
-type AIProviderOption struct {
- ID string `json:"id"`
- Label string `json:"label"`
- Streams bool `json:"streams"`
-}
-
-type AIProviderPath struct {
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` // 要查模型的 provider
-}
-
-type AIProvidersData struct {
- Providers []AIProviderOption `json:"providers"`
-}
-
-type AnalyzeContentFormulaData struct {
- Formula ContentFormulaData `json:"formula"`
- Message string `json:"message"`
-}
-
-type AnalyzeContentFormulaHandlerReq struct {
- ThreadsAccountPath
- AnalyzeContentFormulaReq
-}
-
-type AnalyzeContentFormulaReq struct {
- SourceType string `json:"source_type" validate:"required"`
- PersonaID string `json:"persona_id,optional"`
- PostText string `json:"post_text,optional"`
- MediaID string `json:"media_id,optional"`
- ScanPostID string `json:"scan_post_id,optional"`
- Keyword string `json:"keyword,optional"`
- SaveLabel string `json:"save_label,optional"`
- AuthorName string `json:"author_name,optional"`
- Permalink string `json:"permalink,optional"`
- LikeCount int `json:"like_count,optional"`
- ReplyCount int `json:"reply_count,optional"`
- Views int `json:"views,optional"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Shares int `json:"shares,optional"`
- ForceRefresh bool `json:"force_refresh,optional"`
-}
-
-type AnalyzeStyle8DData struct {
- PersonaID string `json:"persona_id"`
- PostCount int `json:"post_count"`
- StyleProfile string `json:"style_profile"`
- StyleBenchmark string `json:"style_benchmark"`
-}
-
-type AnalyzeStyle8DPostReq struct {
- Text string `json:"text"`
- Permalink string `json:"permalink,optional"`
- LikeCount int `json:"like_count,optional"`
- ReplyCount int `json:"reply_count,optional"`
-}
-
-type AnalyzeStyle8DReq struct {
- ID string `path:"id" validate:"required"`
- WorkerID string `json:"worker_id" validate:"required"`
- TenantID string `json:"tenant_id" validate:"required"`
- OwnerUID string `json:"owner_uid" validate:"required"`
- PersonaID string `json:"persona_id" validate:"required"`
- ThreadsAccountID string `json:"threads_account_id" validate:"required"`
- Username string `json:"username" validate:"required"`
- Posts []AnalyzeStyle8DPostReq `json:"posts" validate:"required"`
- Steps []JobStepProgressData `json:"steps,optional"`
-}
-
-type AuthLoginReq struct {
- TenantID string `json:"tenant_id" validate:"required"`
- Email string `json:"email" validate:"required,email"`
- Password string `json:"password" validate:"required,min=8,max=128"`
-}
-
-type AuthRefreshReq struct {
- RefreshToken string `json:"refresh_token" validate:"required"`
-}
-
-type AuthRegisterReq struct {
- TenantID string `json:"tenant_id" validate:"required"`
- Email string `json:"email" validate:"required,email"`
- Password string `json:"password" validate:"required,min=8,max=128"`
- DisplayName string `json:"display_name,optional"`
- Language string `json:"language,optional"`
-}
-
-type AuthResendVerificationData struct {
- Sent bool `json:"sent"`
-}
-
-type AuthTokenData struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
- ExpiresIn int64 `json:"expires_in"`
- UID string `json:"uid"`
- TokenType string `json:"token_type"`
-}
-
-type AuthVerifyEmailData struct {
- EmailVerified bool `json:"email_verified"`
-}
-
-type AuthVerifyEmailReq struct {
- Code string `json:"code" validate:"required,len=6"`
-}
-
-type BatchDeletePlacementTopicScanPostsData struct {
- DeletedCount int `json:"deleted_count"`
-}
-
-type BatchDeletePlacementTopicScanPostsHandlerReq struct {
- PlacementTopicPath
- BatchDeletePlacementTopicScanPostsReq
-}
-
-type BatchDeletePlacementTopicScanPostsReq struct {
- PostIDs []string `json:"post_ids" validate:"required"`
-}
-
-type BrandData struct {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- TopicName string `json:"topic_name,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- Brief string `json:"brief,omitempty"`
- ProductBrief string `json:"product_brief,omitempty"`
- ProductContext string `json:"product_context,omitempty"`
- ProductID string `json:"product_id,omitempty"`
- Products []BrandProductData `json:"products,omitempty"`
- TargetAudience string `json:"target_audience,omitempty"`
- Goals string `json:"goals,omitempty"`
- ResearchMap ResearchMapData `json:"research_map,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type BrandPath struct {
- ID string `path:"id" validate:"required"`
-}
-
-type BrandProductData struct {
- ID string `json:"id"`
- Label string `json:"label"`
- ProductContext string `json:"product_context"`
- PlacementURL string `json:"placement_url,omitempty"`
- MatchTags []string `json:"match_tags,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type BrandProductPath struct {
- ID string `path:"id" validate:"required"`
- ProductID string `path:"productId" validate:"required"`
-}
-
-type BrandScanScheduleData struct {
- ID string `json:"id,omitempty"`
- BrandID string `json:"brand_id"`
- Cron string `json:"cron"`
- Timezone string `json:"timezone"`
- Enabled bool `json:"enabled"`
- NextRunAt int64 `json:"next_run_at,omitempty"`
- LastRunAt int64 `json:"last_run_at,omitempty"`
-}
-
-type BraveSourceData struct {
- Query string `json:"query,omitempty"`
- Title string `json:"title,omitempty"`
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
-}
-
-type CancelJobReq struct {
- ID string `path:"id" validate:"required"` // job run id
- Reason string `json:"reason,optional"` // cancel reason
-}
-
-type ClaimWorkerJobReq struct {
- WorkerType string `json:"worker_type" validate:"required"`
- WorkerID string `json:"worker_id" validate:"required"`
-}
-
-type ContentFormulaData struct {
- ID string `json:"id"`
- AccountID string `json:"account_id"`
- Label string `json:"label"`
- SourceType string `json:"source_type"`
- SourceRef string `json:"source_ref,omitempty"`
- SourcePostText string `json:"source_post_text,omitempty"`
- SourceAuthor string `json:"source_author,omitempty"`
- SourcePermalink string `json:"source_permalink,omitempty"`
- SourceMetrics *ContentFormulaSourceMetricsData `json:"source_metrics,omitempty"`
- Summary string `json:"summary"`
- Wins []string `json:"wins,omitempty"`
- Improvements []string `json:"improvements,omitempty"`
- Formula string `json:"formula"`
- PostTemplate string `json:"post_template"`
- HookPattern string `json:"hook_pattern"`
- Structure string `json:"structure"`
- ReplicationTips []string `json:"replication_tips,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- Tags []string `json:"tags,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type ContentFormulaItemPath struct {
- ID string `path:"id" validate:"required"`
- FormulaID string `path:"formulaId" validate:"required"`
-}
-
-type ContentFormulaPath struct {
- ID string `path:"id" validate:"required"`
- FormulaID string `path:"formulaId" validate:"required"`
-}
-
-type ContentFormulaSearchPostData struct {
- Text string `json:"text"`
- Author string `json:"author"`
- Permalink string `json:"permalink,omitempty"`
- MediaID string `json:"media_id,omitempty"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- EngagementScore int `json:"engagement_score"`
-}
-
-type ContentFormulaSourceMetricsData struct {
- LikeCount int `json:"like_count,omitempty"`
- ReplyCount int `json:"reply_count,omitempty"`
- Views int `json:"views,omitempty"`
- RepostCount int `json:"repost_count,omitempty"`
- QuoteCount int `json:"quote_count,omitempty"`
- Shares int `json:"shares,omitempty"`
-}
-
-type ContentInboxItemData struct {
- CopyDraftData
- ScheduledAt int64 `json:"scheduled_at,omitempty"`
- Lifecycle string `json:"lifecycle"`
- GroupDate int64 `json:"group_date"`
- FormulaLabel string `json:"formula_label,omitempty"`
-}
-
-type ContentMatrixData struct {
- ID string `json:"id,omitempty"`
- BrandID string `json:"brand_id"`
- Rows []ContentMatrixRowData `json:"rows"`
- GeneratedAt int64 `json:"generated_at"`
- CreateAt int64 `json:"create_at,omitempty"`
- UpdateAt int64 `json:"update_at,omitempty"`
-}
-
-type ContentMatrixRowData struct {
- SortOrder int `json:"sort_order"`
- SearchTag string `json:"search_tag"`
- Angle string `json:"angle"`
- Hook string `json:"hook"`
- Text string `json:"text"`
- ReferenceNotes string `json:"reference_notes"`
- SourcePermalinks []string `json:"source_permalinks"`
- Rationale string `json:"rationale"`
-}
-
-type ContentPlanData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- TopicCandidateID string `json:"topic_candidate_id,omitempty"`
- Topic string `json:"topic"`
- Mission string `json:"mission"`
- TargetAudience string `json:"target_audience,omitempty"`
- Angle string `json:"angle,omitempty"`
- OpeningType string `json:"opening_type,omitempty"`
- BodyType string `json:"body_type,omitempty"`
- Emotion string `json:"emotion,omitempty"`
- EndingType string `json:"ending_type,omitempty"`
- CtaType string `json:"cta_type,omitempty"`
- RiskLevel string `json:"risk_level,omitempty"`
- RequiresHumanReview bool `json:"requires_human_review,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- SelectedKnowledge []string `json:"selected_knowledge,omitempty"`
- Status string `json:"status,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type ContentPlanPath struct {
- ID string `path:"id" validate:"required"`
- ContentPlanID string `path:"contentPlanId" validate:"required"`
-}
-
-type CopyAudienceSampleData struct {
- Username string `json:"username"`
- SamplePostId string `json:"sample_post_id,omitempty"`
- SampleText string `json:"sample_text,omitempty"`
- ReplyLikeCount int `json:"reply_like_count,omitempty"`
- Appearances int `json:"appearances,omitempty"`
- FirstSeenAt int64 `json:"first_seen_at"`
- LastSeenAt int64 `json:"last_seen_at,omitempty"`
- Status string `json:"status,omitempty"`
-}
-
-type CopyDraftData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- ContentPlanID string `json:"content_plan_id,omitempty"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- ScanPostID string `json:"scan_post_id,omitempty"`
- FormulaID string `json:"formula_id,omitempty"`
- DraftType string `json:"draft_type"`
- SortOrder int `json:"sort_order,omitempty"`
- Text string `json:"text"`
- TopicTag string `json:"topic_tag,omitempty"`
- Angle string `json:"angle,omitempty"`
- Hook string `json:"hook,omitempty"`
- Rationale string `json:"rationale,omitempty"`
- ReferenceNotes string `json:"reference_notes,omitempty"`
- Sources []string `json:"sources,omitempty"`
- AiScore int `json:"ai_score,omitempty"`
- FormulaScore int `json:"formula_score,omitempty"`
- BrandFitScore int `json:"brand_fit_score,omitempty"`
- RiskScore int `json:"risk_score,omitempty"`
- SimilarityScore int `json:"similarity_score,omitempty"`
- EngagementPotential int `json:"engagement_potential,omitempty"`
- FreshnessScore int `json:"freshness_score,omitempty"`
- ReviewSuggestion string `json:"review_suggestion,omitempty"`
- Status string `json:"status,omitempty"`
- PublishQueueID string `json:"publish_queue_id,omitempty"`
- PublishedMediaID string `json:"published_media_id,omitempty"`
- PublishedPermalink string `json:"published_permalink,omitempty"`
- PublishedAt int64 `json:"published_at,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type CopyDraftPath struct {
- ID string `path:"id" validate:"required"`
- DraftID string `path:"draftId" validate:"required"`
-}
-
-type CopyMissionData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Label string `json:"label,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- Brief string `json:"brief,omitempty"`
- ResearchMap CopyMissionResearchMapData `json:"research_map,omitempty"`
- SelectedTags []string `json:"selected_tags,omitempty"`
- LastScanJobID string `json:"last_scan_job_id,omitempty"`
- Status string `json:"status,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type CopyMissionInspirationData struct {
- TopicCandidateID string `json:"topic_candidate_id,omitempty"`
- ContentPlanID string `json:"content_plan_id,omitempty"`
- Label string `json:"label"`
- SeedQuery string `json:"seed_query"`
- Brief string `json:"brief"`
- TrendReason string `json:"trend_reason,omitempty"`
- TrendKeywords []string `json:"trend_keywords,omitempty"`
- Angles []string `json:"angles,omitempty"`
- Mission string `json:"mission,omitempty"`
- TargetAudience string `json:"target_audience,omitempty"`
- OpeningType string `json:"opening_type,omitempty"`
- BodyType string `json:"body_type,omitempty"`
- Emotion string `json:"emotion,omitempty"`
- CtaType string `json:"cta_type,omitempty"`
- RiskLevel string `json:"risk_level,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
- WebSearchUsed bool `json:"web_search_used"`
- Message string `json:"message"`
-}
-
-type CopyMissionInspirationHandlerReq struct {
- PersonaCopyMissionsPath
- CopyMissionInspirationReq
-}
-
-type CopyMissionInspirationReq struct {
- Keyword string `json:"keyword,optional"`
- ContentDirection string `json:"content_direction,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- AvoidTopics []string `json:"avoid_topics,optional"`
-}
-
-type CopyMissionInspirationSourceData struct {
- Query string `json:"query,omitempty"`
- Title string `json:"title,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- URL string `json:"url,omitempty"`
-}
-
-type CopyMissionPath struct {
- PersonaID string `path:"personaId" validate:"required"`
- ID string `path:"id" validate:"required"`
-}
-
-type CopyMissionResearchItemData struct {
- Title string `json:"title,omitempty"`
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- Query string `json:"query,omitempty"`
-}
-
-type CopyMissionResearchMapData struct {
- AudienceSummary string `json:"audience_summary,omitempty"`
- ContentGoal string `json:"content_goal,omitempty"`
- Questions []string `json:"questions,omitempty"`
- Pillars []string `json:"pillars,omitempty"`
- Exclusions []string `json:"exclusions,omitempty"`
- KnowledgeItems []string `json:"knowledge_items,omitempty"`
- SelectedKnowledgeItems []string `json:"selected_knowledge_items,omitempty"`
- ResearchItems []CopyMissionResearchItemData `json:"research_items,omitempty"`
- SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
- SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
- AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"`
- BenchmarkNotes string `json:"benchmark_notes,omitempty"`
-}
-
-type CopyMissionScanScheduleData struct {
- ID string `json:"id,omitempty"`
- PersonaID string `json:"persona_id"`
- MissionID string `json:"mission_id"`
- Cron string `json:"cron"`
- Timezone string `json:"timezone"`
- Enabled bool `json:"enabled"`
- NextRunAt int64 `json:"next_run_at,omitempty"`
- LastRunAt int64 `json:"last_run_at,omitempty"`
-}
-
-type CopyMissionScheduleCopyDraftsData struct {
- Scheduled int `json:"scheduled"`
- List []PublishQueueItemData `json:"list"`
- Message string `json:"message"`
-}
-
-type CopyMissionScheduleCopyDraftsReq struct {
- AccountID string `json:"account_id" validate:"required"`
- DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
- StartAt int64 `json:"start_at,optional"`
- Timezone string `json:"timezone,optional"`
- Slots []PublishSlotData `json:"slots,optional"`
- Mode string `json:"mode,optional"`
-}
-
-type CopyMissionSimilarAccountPath struct {
- PersonaID string `path:"personaId" validate:"required"`
- ID string `path:"id" validate:"required"`
- Username string `path:"username" validate:"required"`
-}
-
-type CopyResearchMapData struct {
- AudienceSummary string `json:"audience_summary,omitempty"`
- ContentGoal string `json:"content_goal,omitempty"`
- Questions []string `json:"questions,omitempty"`
- Pillars []string `json:"pillars,omitempty"`
- Exclusions []string `json:"exclusions,omitempty"`
- SuggestedTags []string `json:"suggested_tags,omitempty"`
- BenchmarkNotes string `json:"benchmark_notes,omitempty"`
-}
-
-type CopySimilarAccountData struct {
- Username string `json:"username"`
- Reason string `json:"reason,omitempty"`
- Source string `json:"source,omitempty"`
- MatchedSource []string `json:"matched_source,omitempty"`
- Confidence string `json:"confidence,omitempty"`
- Status string `json:"status,omitempty"`
- TopicRelevance float64 `json:"topic_relevance,omitempty"`
- LastSeenAt int64 `json:"last_seen_at,omitempty"`
- ProfileUrl string `json:"profile_url,omitempty"`
- AuthorVerified bool `json:"author_verified,omitempty"`
- FollowerCount int `json:"follower_count,omitempty"`
- EngagementScore int `json:"engagement_score,omitempty"`
- LikeCount int `json:"like_count,omitempty"`
- ReplyCount int `json:"reply_count,omitempty"`
- PostCount int `json:"post_count,omitempty"`
-}
-
-type CopySuggestedTagData struct {
- Tag string `json:"tag"`
- Reason string `json:"reason,omitempty"`
- SearchIntent string `json:"search_intent,omitempty"`
- SearchType string `json:"search_type,omitempty"`
-}
-
-type CreateBrandProductHandlerReq struct {
- BrandPath
- CreateBrandProductReq
-}
-
-type CreateBrandProductReq struct {
- Label string `json:"label" validate:"required"`
- ProductContext string `json:"product_context" validate:"required"`
- PlacementURL string `json:"placement_url,optional"`
- MatchTags []string `json:"match_tags,optional"`
-}
-
-type CreateBrandReq struct {
- DisplayName string `json:"display_name,optional"`
-}
-
-type CreateContentPlanHandlerReq struct {
- PersonaPath
- CreateContentPlanReq
-}
-
-type CreateContentPlanReq struct {
- TopicCandidateID string `json:"topic_candidate_id,optional"`
- Topic string `json:"topic" validate:"required"`
- Mission string `json:"mission" validate:"required"`
- TargetAudience string `json:"target_audience,optional"`
- Angle string `json:"angle,optional"`
- OpeningType string `json:"opening_type,optional"`
- BodyType string `json:"body_type,optional"`
- Emotion string `json:"emotion,optional"`
- EndingType string `json:"ending_type,optional"`
- CtaType string `json:"cta_type,optional"`
- RiskLevel string `json:"risk_level,optional"`
- RequiresHumanReview bool `json:"requires_human_review,optional"`
- Avoid []string `json:"avoid,optional"`
- SelectedKnowledge []string `json:"selected_knowledge,optional"`
-}
-
-type CreateCopyMissionHandlerReq struct {
- PersonaCopyMissionsPath
- CreateCopyMissionReq
-}
-
-type CreateCopyMissionReq struct {
- Label string `json:"label" validate:"required"`
- SeedQuery string `json:"seed_query" validate:"required"`
- Brief string `json:"brief" validate:"required"`
-}
-
-type CreateFeedbackEventHandlerReq struct {
- PersonaPath
- CreateFeedbackEventReq
-}
-
-type CreateFeedbackEventReq struct {
- ContentPlanID string `json:"content_plan_id,optional"`
- DraftID string `json:"draft_id,optional"`
- Decision string `json:"decision" validate:"required"`
- Note string `json:"note,optional"`
- Snapshot string `json:"snapshot,optional"`
-}
-
-type CreateFormulaPoolHandlerReq struct {
- PersonaPath
- CreateFormulaPoolReq
-}
-
-type CreateFormulaPoolReq struct {
- Type string `json:"type" validate:"required"`
- Name string `json:"name" validate:"required"`
- Pattern string `json:"pattern,optional"`
- UseCases []string `json:"use_cases,optional"`
- Avoid []string `json:"avoid,optional"`
- Weight int `json:"weight,optional"`
-}
-
-type CreateJobReq struct {
- TemplateType string `json:"template_type" validate:"required"` // job template type
- Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope
- ScopeID string `json:"scope_id" validate:"required"` // scope id
- Payload map[string]interface{} `json:"payload,optional"` // job payload
-}
-
-type CreateJobScheduleReq struct {
- TemplateType string `json:"template_type" validate:"required"` // template type
- Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // scope
- ScopeID string `json:"scope_id" validate:"required"` // scope id
- Cron string `json:"cron" validate:"required"` // cron expression
- Timezone string `json:"timezone,optional"` // timezone
- PayloadTemplate map[string]interface{} `json:"payload_template,optional"` // payload template
- Enabled bool `json:"enabled"` // enabled flag
-}
-
-type CreateKnowledgeSourceHandlerReq struct {
- PersonaPath
- CreateKnowledgeSourceReq
-}
-
-type CreateKnowledgeSourceReq struct {
- SourceType string `json:"source_type" validate:"required"`
- Title string `json:"title,optional"`
- Filename string `json:"filename,optional"`
- Content string `json:"content,optional"`
- ContentBase64 string `json:"content_base64,optional"`
-}
-
-type CreatePersonaReq struct {
- DisplayName string `json:"display_name,optional"`
-}
-
-type CreatePlacementTopicHandlerReq struct {
- CreatePlacementTopicReq
-}
-
-type CreatePlacementTopicReq struct {
- BrandID string `json:"brand_id" validate:"required"`
- TopicName string `json:"topic_name" validate:"required"`
- SeedQuery string `json:"seed_query" validate:"required"`
- Brief string `json:"brief" validate:"required"`
- ProductID string `json:"product_id,optional"`
-}
-
-type CreatePublishQueueHandlerReq struct {
- ThreadsAccountPath
- CreatePublishQueueReq
-}
-
-type CreatePublishQueueReq struct {
- Text string `json:"text,optional"`
- ImageKey string `json:"image_key,optional"`
- ImageKeys []string `json:"image_keys,optional"`
- TopicTag string `json:"topic_tag,optional"`
- ScheduledAt int64 `json:"scheduled_at,optional"`
-}
-
-type CreateThreadsAccountReq struct {
- DisplayName string `json:"display_name,optional"`
- Activate *bool `json:"activate,optional"`
-}
-
-type DeleteContentFormulaData struct {
- FormulaID string `json:"formula_id"`
- Message string `json:"message"`
-}
-
-type DeleteCopyDraftData struct {
- DraftID string `json:"draft_id"`
- Message string `json:"message"`
-}
-
-type DeleteCopyMissionMatrixDraftsData struct {
- Deleted int `json:"deleted"`
- Message string `json:"message"`
-}
-
-type DeleteCopyMissionMatrixDraftsHandlerReq struct {
- CopyMissionPath
- DeleteCopyMissionMatrixDraftsReq
-}
-
-type DeleteCopyMissionMatrixDraftsReq struct {
- DraftIDs []string `json:"draft_ids,optional"`
-}
-
-type DeletePlacementTopicScanPostHandlerReq struct {
- PlacementTopicPath
- PostID string `path:"postId" validate:"required"`
-}
-
-type DeleteThreadsAccountData struct {
- DeletedID string `json:"deleted_id"`
- ActiveAccountID string `json:"active_account_id,omitempty"`
- Message string `json:"message"`
-}
-
-type ErrorDetail struct {
- BizCode string `json:"biz_code,optional"`
- Scope int64 `json:"scope,optional"`
- Category int64 `json:"category,optional"`
- Detail int64 `json:"detail,optional"`
-}
-
-type ExpandCopyMissionGraphHandlerReq struct {
- CopyMissionPath
- ExpandKnowledgeGraphReq
-}
-
-type ExpandKnowledgeGraphData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type ExpandKnowledgeGraphHandlerReq struct {
- BrandPath
- ExpandKnowledgeGraphReq
-}
-
-type ExpandKnowledgeGraphReq struct {
- SeedQuery string `json:"seed_query" validate:"required"`
- Supplemental bool `json:"supplemental,optional"`
- RegenerateMap bool `json:"regenerate_map,optional"`
- ExpandStrategy string `json:"expand_strategy,optional"` // brave | llm | hybrid
-}
-
-type ExpandPlacementTopicGraphHandlerReq struct {
- PlacementTopicPath
- ExpandKnowledgeGraphReq
-}
-
-type FeedbackEventData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- ContentPlanID string `json:"content_plan_id,omitempty"`
- DraftID string `json:"draft_id,omitempty"`
- Decision string `json:"decision"`
- Note string `json:"note,omitempty"`
- Snapshot string `json:"snapshot,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type FormulaPoolData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Type string `json:"type"`
- Name string `json:"name"`
- Pattern string `json:"pattern,omitempty"`
- UseCases []string `json:"use_cases,omitempty"`
- Avoid []string `json:"avoid,omitempty"`
- Weight int `json:"weight,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type GenerateContentMatrixHandlerReq struct {
- BrandPath
- GenerateContentMatrixReq
-}
-
-type GenerateContentMatrixReq struct {
- Count int `json:"count,optional"`
-}
-
-type GenerateCopyMissionMatrixData struct {
- Drafts []CopyDraftData `json:"drafts"`
- Message string `json:"message"`
-}
-
-type GenerateCopyMissionMatrixHandlerReq struct {
- CopyMissionPath
- GenerateCopyMissionMatrixReq
-}
-
-type GenerateCopyMissionMatrixReq struct {
- Count int `json:"count,optional"`
-}
-
-type GenerateFromContentFormulaData struct {
- List []CopyDraftData `json:"list"`
- Message string `json:"message"`
-}
-
-type GenerateFromContentFormulaHandlerReq struct {
- ContentFormulaPath
- GenerateFromContentFormulaReq
-}
-
-type GenerateFromContentFormulaReq struct {
- AccountID string `json:"account_id" validate:"required"`
- Topic string `json:"topic" validate:"required"`
- Brief string `json:"brief,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
-}
-
-type GenerateOutreachDraftsData struct {
- ID string `json:"id"`
- ScanPostID string `json:"scan_post_id"`
- Relevance float64 `json:"relevance"`
- Reason string `json:"reason"`
- Drafts []OutreachDraftItemData `json:"drafts"`
- CreateAt int64 `json:"create_at"`
-}
-
-type GenerateOutreachDraftsHandlerReq struct {
- BrandPath
- GenerateOutreachDraftsReq
-}
-
-type GenerateOutreachDraftsReq struct {
- ScanPostID string `json:"scan_post_id" validate:"required"`
- TopicID string `json:"topic_id,optional"`
- Count int `json:"count,optional"`
- VoicePersonaID string `json:"voice_persona_id,optional"`
- ProductID string `json:"product_id,optional"`
- Regenerate bool `json:"regenerate,optional"`
-}
-
-type GenerateOwnPostFormulaData struct {
- OwnPostFormulaReviewData
-}
-
-type GenerateOwnPostFormulaHandlerReq struct {
- ThreadsAccountPath
- GenerateOwnPostFormulaReq
-}
-
-type GenerateOwnPostFormulaReq struct {
- MediaID string `json:"media_id"`
- PersonaID string `json:"persona_id,optional"`
- PostText string `json:"post_text,optional"`
- TopicTag string `json:"topic_tag,optional"`
- MediaType string `json:"media_type,optional"`
- Timestamp string `json:"timestamp,optional"`
- LikeCount int `json:"like_count,optional"`
- ReplyCount int `json:"reply_count,optional"`
- Views int `json:"views,optional"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Shares int `json:"shares,optional"`
- Force bool `json:"force,optional"`
-}
-
-type GenerateOwnPostReplyDraftData struct {
- Text string `json:"text"`
-}
-
-type GenerateOwnPostReplyDraftHandlerReq struct {
- ThreadsAccountPath
- GenerateOwnPostReplyDraftReq
-}
-
-type GenerateOwnPostReplyDraftReq struct {
- MediaID string `json:"media_id"`
- PersonaID string `json:"persona_id,optional"`
- ReplyToID string `json:"reply_to_id,optional"`
- PostText string `json:"post_text,optional"`
- ReplyText string `json:"reply_text,optional"`
- ThreadPostText string `json:"thread_post_text,optional"`
- ParentReplyText string `json:"parent_reply_text,optional"`
-}
-
-type GeneratePersonaCopyDraftData struct {
- Draft CopyDraftData `json:"draft"`
- Message string `json:"message"`
-}
-
-type GeneratePersonaCopyDraftHandlerReq struct {
- PersonaPath
- GeneratePersonaCopyDraftReq
-}
-
-type GeneratePersonaCopyDraftReq struct {
- ScanPostID string `json:"scan_post_id" validate:"required"`
-}
-
-type GeneratePersonaTopicMatrixData struct {
- List []CopyDraftData `json:"list"`
- Message string `json:"message"`
-}
-
-type GeneratePersonaTopicMatrixHandlerReq struct {
- PersonaPath
- GeneratePersonaTopicMatrixReq
-}
-
-type GeneratePersonaTopicMatrixReq struct {
- Topic string `json:"topic" validate:"required"`
- ContentPlanID string `json:"content_plan_id,optional"`
- Brief string `json:"brief,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
-}
-
-type GeneratePlacementTopicContentMatrixHandlerReq struct {
- PlacementTopicPath
- GenerateContentMatrixReq
-}
-
-type GeneratePlacementTopicOutreachDraftsHandlerReq struct {
- PlacementTopicPath
- GenerateOutreachDraftsReq
-}
-
-type GetContentFormulaHandlerReq struct {
- ContentFormulaItemPath
-}
-
-type HealthData struct {
- Pong string `json:"pong"`
-}
-
-type ImportOwnPostFormulaHandlerReq struct {
- ThreadsAccountPath
- ImportOwnPostFormulaReq
-}
-
-type ImportOwnPostFormulaReq struct {
- MediaID string `json:"media_id" validate:"required"`
- Label string `json:"label,optional"`
-}
-
-type ImportThreadsAccountSessionData struct {
- Success bool `json:"success"`
- Valid bool `json:"valid"`
- Synced bool `json:"synced"`
- AccountID string `json:"account_id"`
- Username string `json:"username,omitempty"`
- Message string `json:"message"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type ImportThreadsAccountSessionHandlerReq struct {
- ThreadsAccountPath
- ImportThreadsAccountSessionReq
-}
-
-type ImportThreadsAccountSessionReq struct {
- StorageState string `json:"storageState" validate:"required"`
-}
-
-type IslanderChatReq struct {
- Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
- Context string `json:"context,optional"` // 目前頁面與站內導覽快照
-}
-
-type JobCancelPolicyData struct {
- Supported bool `json:"supported"`
- Mode string `json:"mode"`
- GraceSeconds int `json:"grace_seconds"`
-}
-
-type JobData struct {
- ID string `json:"id"`
- TemplateType string `json:"template_type"`
- TemplateVersion int `json:"template_version"`
- Scope string `json:"scope"`
- ScopeID string `json:"scope_id"`
- Status string `json:"status"`
- Phase string `json:"phase"`
- WorkerType string `json:"worker_type"`
- Payload map[string]interface{} `json:"payload"`
- Progress JobProgressData `json:"progress"`
- Result map[string]interface{} `json:"result,optional"`
- Error string `json:"error,optional"`
- Attempt int `json:"attempt"`
- MaxAttempts int `json:"max_attempts"`
- CancelRequestedAt *int64 `json:"cancel_requested_at,optional"`
- CancelReason string `json:"cancel_reason,optional"`
- StartedAt *int64 `json:"started_at,optional"`
- CompletedAt *int64 `json:"completed_at,optional"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type JobEventData struct {
- ID string `json:"id"`
- JobID string `json:"job_id"`
- Type string `json:"type"`
- From string `json:"from,optional"`
- To string `json:"to,optional"`
- Message string `json:"message"`
- Metadata map[string]interface{} `json:"metadata,optional"`
- CreateAt int64 `json:"create_at"`
-}
-
-type JobEventListData struct {
- List []JobEventData `json:"list"`
-}
-
-type JobIDPath struct {
- ID string `path:"id" validate:"required"` // job run id
-}
-
-type JobListData struct {
- Pagination PaginationData `json:"pagination"`
- List []JobData `json:"list"`
-}
-
-type JobProgressData struct {
- Summary string `json:"summary"`
- Percentage int `json:"percentage"`
- Steps []JobStepProgressData `json:"steps"`
-}
-
-type JobRetryPolicyData struct {
- MaxAttempts int `json:"max_attempts"`
- BackoffSeconds []int `json:"backoff_seconds"`
-}
-
-type JobScheduleData struct {
- ID string `json:"id"`
- TemplateType string `json:"template_type"`
- Scope string `json:"scope"`
- ScopeID string `json:"scope_id"`
- Enabled bool `json:"enabled"`
- Cron string `json:"cron"`
- Timezone string `json:"timezone"`
- PayloadTemplate map[string]interface{} `json:"payload_template"`
- LastRunAt *int64 `json:"last_run_at,optional"`
- NextRunAt int64 `json:"next_run_at"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type JobScheduleIDPath struct {
- ID string `path:"id" validate:"required"` // schedule id
-}
-
-type JobScheduleListData struct {
- Pagination PaginationData `json:"pagination"`
- List []JobScheduleData `json:"list"`
-}
-
-type JobStepProgressData struct {
- ID string `json:"id"`
- Status string `json:"status"`
- StartedAt *int64 `json:"started_at,optional"`
- EndedAt *int64 `json:"ended_at,optional"`
- Message string `json:"message,optional"`
-}
-
-type JobTemplateData struct {
- Type string `json:"type"`
- Version int `json:"version"`
- Name string `json:"name"`
- Description string `json:"description"`
- Enabled bool `json:"enabled"`
- Repeatable bool `json:"repeatable"`
- ConcurrencyPolicy string `json:"concurrency_policy"`
- DedupeKeys []string `json:"dedupe_keys"`
- TimeoutSeconds int `json:"timeout_seconds"`
- CancelPolicy JobCancelPolicyData `json:"cancel_policy"`
- RetryPolicy JobRetryPolicyData `json:"retry_policy"`
- Steps []JobTemplateStepData `json:"steps"`
-}
-
-type JobTemplateListData struct {
- List []JobTemplateData `json:"list"`
-}
-
-type JobTemplatePath struct {
- Type string `path:"type" validate:"required"` // template type
-}
-
-type JobTemplateStepData struct {
- ID string `json:"id"`
- Name string `json:"name"`
- WorkerType string `json:"worker_type"`
- TimeoutSeconds int `json:"timeout_seconds"`
- Cancelable bool `json:"cancelable"`
-}
-
-type KnowledgeChunkData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- SourceID string `json:"source_id"`
- Content string `json:"content"`
- Topics []string `json:"topics,omitempty"`
- StyleTags []string `json:"style_tags,omitempty"`
- RiskLevel string `json:"risk_level,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type KnowledgeGraphData struct {
- ID string `json:"id"`
- BrandID string `json:"brand_id"`
- Seed string `json:"seed"`
- Nodes []KnowledgeGraphNodeData `json:"nodes"`
- Edges []KnowledgeGraphEdgeData `json:"edges"`
- BraveSources []BraveSourceData `json:"brave_sources,omitempty"`
- ExpandStrategy string `json:"expand_strategy,omitempty"`
- PainTagCount int `json:"pain_tag_count"`
- GeneratedAt int64 `json:"generated_at"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type KnowledgeGraphEdgeData struct {
- From string `json:"from"`
- To string `json:"to"`
- Relation string `json:"relation"`
-}
-
-type KnowledgeGraphEvidenceData struct {
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- Query string `json:"query,omitempty"`
-}
-
-type KnowledgeGraphNodeData struct {
- ID string `json:"id"`
- Label string `json:"label"`
- NodeKind string `json:"node_kind"`
- Type string `json:"type"`
- Layer int `json:"layer"`
- Relation string `json:"relation,omitempty"`
- PlacementValue string `json:"placement_value,omitempty"`
- ProductFitScore int `json:"product_fit_score"`
- SelectedForScan bool `json:"selected_for_scan"`
- RelevanceTags []string `json:"relevance_tags"`
- RecencyTags []string `json:"recency_tags"`
- Evidence []KnowledgeGraphEvidenceData `json:"evidence,omitempty"`
-}
-
-type KnowledgeGraphNodeUpdate struct {
- NodeID string `json:"node_id" validate:"required"`
- SelectedForScan *bool `json:"selected_for_scan,optional"`
- RelevanceTags []string `json:"relevance_tags,optional"`
- RecencyTags []string `json:"recency_tags,optional"`
-}
-
-type KnowledgeSourceData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- SourceType string `json:"source_type"`
- Title string `json:"title,omitempty"`
- Filename string `json:"filename,omitempty"`
- ParsedStatus string `json:"parsed_status,omitempty"`
- ChunkCount int `json:"chunk_count,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type ListBrandProductsData struct {
- List []BrandProductData `json:"list"`
-}
-
-type ListBrandScanPostsData struct {
- List []ScanPostData `json:"list"`
- Total int `json:"total"`
-}
-
-type ListBrandScanPostsHandlerReq struct {
- BrandPath
- ListBrandScanPostsReq
-}
-
-type ListBrandScanPostsReq struct {
- Priority string `form:"priority,optional"`
- Recent7d bool `form:"recent_7d,optional"`
- ProductFitMin int `form:"product_fit_min,optional"`
- Limit int `form:"limit,optional"`
-}
-
-type ListBrandsData struct {
- List []BrandData `json:"list"`
-}
-
-type ListContentFormulasData struct {
- Pagination PaginationData `json:"pagination"`
- List []ContentFormulaData `json:"list"`
-}
-
-type ListContentFormulasHandlerReq struct {
- ThreadsAccountPath
- ListContentFormulasReq
-}
-
-type ListContentFormulasReq struct {
- Page int64 `form:"page,optional"`
- PageSize int64 `form:"pageSize,optional"`
- SourceType string `form:"source_type,optional"`
- Tag string `form:"tag,optional"`
-}
-
-type ListContentPlansData struct {
- List []ContentPlanData `json:"list"`
-}
-
-type ListCopyMissionCopyDraftsData struct {
- List []CopyDraftData `json:"list"`
- Total int `json:"total"`
-}
-
-type ListCopyMissionScanPostsHandlerReq struct {
- CopyMissionPath
- ListCopyMissionScanPostsReq
-}
-
-type ListCopyMissionScanPostsReq struct {
- Limit int `form:"limit,optional"`
-}
-
-type ListCopyMissionsData struct {
- List []CopyMissionData `json:"list"`
-}
-
-type ListFormulaPoolsData struct {
- List []FormulaPoolData `json:"list"`
-}
-
-type ListJobEventsReq struct {
- ID string `path:"id" validate:"required"` // job run id
- Limit int64 `form:"limit,optional"` // max events
-}
-
-type ListJobSchedulesReq struct {
- Scope string `form:"scope,optional"` // filter by scope
- ScopeID string `form:"scope_id,optional"` // filter by scope id
- Page int64 `form:"page,optional"` // page number starting at 1
- PageSize int64 `form:"pageSize,optional"` // page size
-}
-
-type ListJobsReq struct {
- Scope string `form:"scope,optional"` // filter by scope
- ScopeID string `form:"scope_id,optional"` // filter by scope id
- Page int64 `form:"page,optional"` // page number starting at 1
- PageSize int64 `form:"pageSize,optional"` // page size
-}
-
-type ListKnowledgeChunksData struct {
- List []KnowledgeChunkData `json:"list"`
-}
-
-type ListKnowledgeSourcesData struct {
- List []KnowledgeSourceData `json:"list"`
-}
-
-type ListMembersData struct {
- Pagination PaginationData `json:"pagination"`
- List []MemberMeData `json:"list"`
-}
-
-type ListMembersReq struct {
- Page int64 `form:"page,optional"`
- PageSize int64 `form:"pageSize,optional"`
-}
-
-type ListMentionInboxData struct {
- List []MentionInboxItemData `json:"list"`
- Pagination PaginationData `json:"pagination"`
- ReadyTotal int64 `json:"ready_total"`
- NewestSyncedAt int64 `json:"newest_synced_at,omitempty"`
-}
-
-type ListMentionInboxHandlerReq struct {
- ThreadsAccountPath
- ListMentionInboxQuery
-}
-
-type ListMentionInboxQuery struct {
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
-}
-
-type ListOwnPostFormulasData struct {
- List []OwnPostFormulaReviewData `json:"list"`
-}
-
-type ListOwnPostFormulasHandlerReq struct {
- ThreadsAccountPath
-}
-
-type ListPersonaContentInboxData struct {
- Pagination PaginationData `json:"pagination"`
- List []ContentInboxItemData `json:"list"`
-}
-
-type ListPersonaContentInboxHandlerReq struct {
- PersonaPath
- ListPersonaContentInboxReq
-}
-
-type ListPersonaContentInboxReq struct {
- Page int64 `form:"page,optional"`
- PageSize int64 `form:"pageSize,optional"`
- Status string `form:"status,optional"`
- RangeStart int64 `form:"rangeStart,optional"`
- RangeEnd int64 `form:"rangeEnd,optional"`
-}
-
-type ListPersonaCopyDraftsData struct {
- List []CopyDraftData `json:"list"`
- Total int `json:"total"`
-}
-
-type ListPersonaViralScanPostsData struct {
- List []ViralScanPostData `json:"list"`
- Total int `json:"total"`
-}
-
-type ListPersonaViralScanPostsHandlerReq struct {
- PersonaPath
- ListPersonaViralScanPostsReq
-}
-
-type ListPersonaViralScanPostsReq struct {
- Limit int `form:"limit,optional"`
-}
-
-type ListPersonasData struct {
- List []PersonaData `json:"list"`
-}
-
-type ListPlacementTopicScanPostsHandlerReq struct {
- PlacementTopicPath
- ListBrandScanPostsReq
-}
-
-type ListPlacementTopicsData struct {
- List []PlacementTopicData `json:"list"`
-}
-
-type ListPublishQueueHandlerReq struct {
- ThreadsAccountPath
- ListPublishQueueQuery
-}
-
-type ListPublishQueueQuery struct {
- Status string `form:"status,optional"`
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
-}
-
-type ListStylePresetsData struct {
- List []StylePresetData `json:"list"`
-}
-
-type ListThreadsAccountAiProviderModelsReq struct {
- ApiKey string `json:"api_key,optional"` // 選填;未帶則用已儲存的 key
-}
-
-type ListThreadsAccountsData struct {
- List []ThreadsAccountData `json:"list"`
- ActiveAccountID string `json:"active_account_id"`
-}
-
-type ListThreadsOAuthLogsData struct {
- List []ThreadsOAuthLogItem `json:"list"`
-}
-
-type ListThreadsPostPerformanceHandlerReq struct {
- ThreadsAccountPath
- ListThreadsPostPerformanceQuery
-}
-
-type ListThreadsPostPerformanceQuery struct {
- Limit int `form:"limit,optional"`
-}
-
-type ListThreadsPublishHealthHandlerReq struct {
- ThreadsAccountPath
- ListThreadsPublishHealthQuery
-}
-
-type ListThreadsPublishHealthQuery struct {
- Page int `form:"page,optional"`
- PageSize int `form:"pageSize,optional"`
-}
-
-type ListTopicCandidatesData struct {
- List []TopicCandidateData `json:"list"`
-}
-
-type LogoutData struct {
- OK bool `json:"ok"`
-}
-
-type MePermissionsData struct {
- UID string `json:"uid"`
- TenantID string `json:"tenant_id"`
- Roles []string `json:"roles"`
- Permissions map[string]string `json:"permissions"`
- Tree []PermissionNode `json:"tree,omitempty"`
-}
-
-type MePermissionsQuery struct {
- IncludeTree bool `form:"include_tree,optional"`
-}
-
-type MemberAiProviderModelsData struct {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,omitempty"`
-}
-
-type MemberAiProviderModelsReq struct {
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
- ApiKey string `json:"api_key,optional"`
-}
-
-type MemberAiSettingsData struct {
- Provider string `json:"provider"`
- Model string `json:"model"`
- ResearchProvider string `json:"research_provider,omitempty"`
- ResearchModel string `json:"research_model,omitempty"`
- ApiKeys map[string]string `json:"api_keys,omitempty"`
- ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
-}
-
-type MemberCapabilitiesData struct {
- DiscoverReady bool `json:"discover_ready"`
- AiReady bool `json:"ai_ready"`
- PublishReady bool `json:"publish_ready"`
- DiscoverHint string `json:"discover_hint,omitempty"`
- AiHint string `json:"ai_hint,omitempty"`
- PublishHint string `json:"publish_hint,omitempty"`
- ActiveThreadsAccountId string `json:"active_threads_account_id,omitempty"`
-}
-
-type MemberMeData struct {
- TenantID string `json:"tenant_id"`
- UID string `json:"uid"`
- Email string `json:"email"`
- EmailVerified bool `json:"email_verified"`
- DisplayName string `json:"display_name,omitempty"`
- Avatar string `json:"avatar,omitempty"`
- Phone string `json:"phone,omitempty"`
- Language string `json:"language,omitempty"`
- Currency string `json:"currency,omitempty"`
- Status string `json:"status"`
- Origin string `json:"origin"`
- Roles []string `json:"roles,omitempty"`
- BusinessEmail string `json:"business_email,omitempty"`
- BusinessEmailVerified bool `json:"business_email_verified"`
- BusinessPhone string `json:"business_phone,omitempty"`
- BusinessPhoneVerified bool `json:"business_phone_verified"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type MemberPath struct {
- UID string `path:"uid" validate:"required"`
-}
-
-type MemberPlacementSettingsData struct {
- WebSearchProvider string `json:"web_search_provider"` // brave | exa
- BraveAPIKey string `json:"brave_api_key,omitempty"`
- BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
- ExaAPIKey string `json:"exa_api_key,omitempty"`
- ExaAPIKeyConfigured bool `json:"exa_api_key_configured"`
- BraveCountry string `json:"brave_country"`
- BraveSearchLang string `json:"brave_search_lang"`
- ExaUserLocation string `json:"exa_user_location"`
- ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
- DevModeEnabled bool `json:"dev_mode_enabled"`
-}
-
-type MentionInboxItemData struct {
- MediaID string `json:"media_id"`
- AuthorUsername string `json:"author_username,omitempty"`
- Text string `json:"text,omitempty"`
- Permalink string `json:"permalink,omitempty"`
- Shortcode string `json:"shortcode,omitempty"`
- Timestamp string `json:"timestamp,omitempty"`
- MediaType string `json:"media_type,omitempty"`
- IsReply bool `json:"is_reply"`
- IsQuotePost bool `json:"is_quote_post"`
- HasReplies bool `json:"has_replies"`
- RootPostID string `json:"root_post_id,omitempty"`
- ParentID string `json:"parent_id,omitempty"`
- ThreadPostText string `json:"thread_post_text,omitempty"`
- ParentReplyText string `json:"parent_reply_text,omitempty"`
- ReplyReady bool `json:"reply_ready"`
- ReplyReadyMsg string `json:"reply_ready_msg,omitempty"`
- SyncedAt int64 `json:"synced_at"`
-}
-
-type OutreachDraftItemData struct {
- Text string `json:"text"`
- Angle string `json:"angle"`
- Rationale string `json:"rationale"`
-}
-
-type OwnPostFormulaReviewData struct {
- MediaID string `json:"media_id"`
- ReviewedAt int64 `json:"reviewed_at"`
- FromCache bool `json:"from_cache,omitempty"`
- Summary string `json:"summary"`
- Wins []string `json:"wins"`
- Improvements []string `json:"improvements"`
- Formula string `json:"formula"`
- PostTemplate string `json:"post_template"`
- HookPattern string `json:"hook_pattern"`
- Structure string `json:"structure"`
- ReplicationTips []string `json:"replication_tips"`
- Avoid []string `json:"avoid"`
-}
-
-type PaginationData struct {
- Total int64 `json:"total"`
- Page int64 `json:"page"`
- PageSize int64 `json:"pageSize"`
- TotalPages int64 `json:"totalPages"`
-}
-
-type PatchAudienceSampleHandlerReq struct {
- CopyMissionSimilarAccountPath
- PatchAudienceSampleReq
-}
-
-type PatchAudienceSampleReq struct {
- Status string `json:"status"`
-}
-
-type PatchContentFormulaHandlerReq struct {
- ContentFormulaItemPath
- PatchContentFormulaReq
-}
-
-type PatchContentFormulaReq struct {
- Label *string `json:"label,optional"`
- Formula *string `json:"formula,optional"`
- PostTemplate *string `json:"post_template,optional"`
- HookPattern *string `json:"hook_pattern,optional"`
- Structure *string `json:"structure,optional"`
- Tags []string `json:"tags,optional"`
-}
-
-type PatchCopyMissionGraphNodesHandlerReq struct {
- CopyMissionPath
- PatchKnowledgeGraphNodesReq
-}
-
-type PatchKnowledgeGraphNodesHandlerReq struct {
- BrandPath
- PatchKnowledgeGraphNodesReq
-}
-
-type PatchKnowledgeGraphNodesReq struct {
- Updates []KnowledgeGraphNodeUpdate `json:"updates" validate:"required"`
-}
-
-type PatchPlacementTopicGraphNodesHandlerReq struct {
- PlacementTopicPath
- PatchKnowledgeGraphNodesReq
-}
-
-type PatchPlacementTopicScanPostOutreachHandlerReq struct {
- PlacementTopicPath
- PostID string `path:"postId"`
- PatchScanPostOutreachReq
-}
-
-type PatchPublishQueueHandlerReq struct {
- PublishQueueItemPath
- PatchPublishQueueReq
-}
-
-type PatchPublishQueueReq struct {
- Text *string `json:"text,optional"`
- ImageKey *string `json:"image_key,optional"`
- ImageKeys *[]string `json:"image_keys,optional"`
- TopicTag *string `json:"topic_tag,optional"`
- ScheduledAt *int64 `json:"scheduled_at,optional"`
-}
-
-type PatchScanPostOutreachHandlerReq struct {
- BrandPath
- PostID string `path:"postId"`
- PatchScanPostOutreachReq
-}
-
-type PatchScanPostOutreachReq struct {
- OutreachStatus *string `json:"outreach_status,optional"`
-}
-
-type PatchSimilarAccountHandlerReq struct {
- CopyMissionSimilarAccountPath
- PatchSimilarAccountReq
-}
-
-type PatchSimilarAccountReq struct {
- Status string `json:"status"`
-}
-
-type PermissionCatalogData struct {
- Tree []PermissionNode `json:"tree,omitempty"`
- List []PermissionNode `json:"list,omitempty"`
-}
-
-type PermissionCatalogQuery struct {
- Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
- Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
- Tree bool `form:"tree,optional"`
-}
-
-type PermissionNode struct {
- ID string `json:"id"`
- Parent string `json:"parent,omitempty"`
- Name string `json:"name"`
- HTTPMethods string `json:"http_methods,omitempty"`
- HTTPPath string `json:"http_path,omitempty"`
- Status string `json:"status"`
- Type string `json:"type"`
- Children []PermissionNode `json:"children,omitempty"`
-}
-
-type PersonaCopyMissionsPath struct {
- PersonaID string `path:"personaId" validate:"required"`
-}
-
-type PersonaData struct {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- Persona string `json:"persona,omitempty"`
- Brief string `json:"brief,omitempty"`
- StyleProfile string `json:"style_profile,omitempty"`
- StyleBenchmark string `json:"style_benchmark,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- CopyResearchMap CopyResearchMapData `json:"copy_research_map,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type PersonaPath struct {
- ID string `path:"id" validate:"required"`
-}
-
-type PlacementTopicData struct {
- ID string `json:"id"`
- BrandID string `json:"brand_id"`
- BrandDisplayName string `json:"brand_display_name,omitempty"`
- TopicName string `json:"topic_name,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- Brief string `json:"brief,omitempty"`
- ProductID string `json:"product_id,omitempty"`
- ResearchMap ResearchMapData `json:"research_map,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type PlacementTopicPath struct {
- ID string `path:"id" validate:"required"`
-}
-
-type PrunePersonaCopyDraftsData struct {
- Deleted int64 `json:"deleted"`
- Kept int `json:"kept"`
- Message string `json:"message"`
-}
-
-type PrunePersonaCopyDraftsHandlerReq struct {
- PersonaPath
- PrunePersonaCopyDraftsReq
-}
-
-type PrunePersonaCopyDraftsReq struct {
- Keep int `json:"keep,optional"`
-}
-
-type PublishAlertData struct {
- Type string `json:"type"`
- Severity string `json:"severity"`
- Message string `json:"message"`
- QueueID string `json:"queue_id,omitempty"`
- AccountID string `json:"account_id"`
- CreateAt int64 `json:"create_at"`
-}
-
-type PublishAlertsData struct {
- List []PublishAlertData `json:"list"`
-}
-
-type PublishCopyDraftData struct {
- DraftID string `json:"draft_id"`
- MediaID string `json:"media_id"`
- Permalink string `json:"permalink,omitempty"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type PublishCopyDraftHandlerReq struct {
- CopyDraftPath
- PublishCopyDraftReq
-}
-
-type PublishCopyDraftReq struct {
- Text string `json:"text,optional"`
- TopicTag string `json:"topic_tag,optional"`
- Confirm bool `json:"confirm"`
-}
-
-type PublishDashboardAccountSummary struct {
- AccountID string `json:"account_id"`
- AccountName string `json:"account_name"`
- PendingScheduled int64 `json:"pending_scheduled"`
- FailedCount int64 `json:"failed_count"`
- Published7d int64 `json:"published_7d"`
- TotalLikes7d int `json:"total_likes_7d"`
- TotalReplies7d int `json:"total_replies_7d"`
- Paused bool `json:"paused"`
- BestSlot string `json:"best_slot,omitempty"`
- LowSlot string `json:"low_slot,omitempty"`
-}
-
-type PublishDashboardSummaryData struct {
- List []PublishDashboardAccountSummary `json:"list"`
- TotalPending int64 `json:"total_pending"`
- TotalFailed int64 `json:"total_failed"`
- TotalPublished7d int64 `json:"total_published_7d"`
- TotalLikes7d int `json:"total_likes_7d"`
- TotalReplies7d int `json:"total_replies_7d"`
-}
-
-type PublishGuardPolicyData struct {
- AccountID string `json:"account_id"`
- Enabled bool `json:"enabled"`
- MaxDailyPosts int `json:"max_daily_posts"`
- MinIntervalMinutes int `json:"min_interval_minutes"`
- ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit"`
- Paused bool `json:"paused"`
- PausedReason string `json:"paused_reason,omitempty"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type PublishInventoryPolicyData struct {
- AccountID string `json:"account_id"`
- Enabled bool `json:"enabled"`
- TargetDailyCount int `json:"target_daily_count"`
- LowStockThreshold int `json:"low_stock_threshold"`
- LookaheadDays int `json:"lookahead_days"`
- Timezone string `json:"timezone"`
- Slots []PublishSlotData `json:"slots"`
- SourceRefs []PublishSourceRefData `json:"source_refs"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type PublishMentionReplyData struct {
- MediaID string `json:"media_id,omitempty"`
- Permalink string `json:"permalink,omitempty"`
-}
-
-type PublishThreadsReplyData struct {
- MediaID string `json:"media_id,omitempty"`
- Permalink string `json:"permalink,omitempty"`
-}
-
-type PublishThreadsReplyHandlerReq struct {
- ID string `path:"id" validate:"required"`
- ReplyToID string `form:"reply_to_id" validate:"required"`
- Text string `form:"text,optional"`
-}
-
-type PublishMentionReplyHandlerReq struct {
- PublishMentionReplyPath
- PublishMentionReplyReq
-}
-
-type PublishMentionReplyPath struct {
- ID string `path:"id" validate:"required"`
- MediaID string `path:"media_id" validate:"required"`
-}
-
-type PublishMentionReplyReq struct {
- Text string `json:"text,optional"`
- ImageKey string `json:"image_key,optional"`
- ImageKeys []string `json:"image_keys,optional"`
-}
-
-type PublishOutreachDraftData struct {
- ScanPostID string `json:"scan_post_id"`
- ReplyID string `json:"reply_id"`
- Permalink string `json:"permalink"`
- OutreachStatus string `json:"outreach_status"`
- PublishedPermalink string `json:"published_permalink"`
- Message string `json:"message"`
-}
-
-type PublishOutreachDraftHandlerReq struct {
- BrandPath
- PublishOutreachDraftReq
-}
-
-type PublishOutreachDraftReq struct {
- ScanPostID string `json:"scan_post_id" validate:"required"`
- Text string `json:"text" validate:"required"`
- Confirm bool `json:"confirm"`
-}
-
-type PublishPlacementTopicOutreachDraftHandlerReq struct {
- PlacementTopicPath
- PublishOutreachDraftReq
-}
-
-type PublishQueueEventData struct {
- ID string `json:"id"`
- QueueID string `json:"queue_id"`
- EventType string `json:"event_type"`
- FromStatus string `json:"from_status,omitempty"`
- ToStatus string `json:"to_status,omitempty"`
- Message string `json:"message,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type PublishQueueEventsData struct {
- List []PublishQueueEventData `json:"list"`
-}
-
-type PublishQueueItemData struct {
- ID string `json:"id"`
- AccountID string `json:"account_id"`
- PersonaID string `json:"persona_id,omitempty"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- CopyDraftID string `json:"copy_draft_id,omitempty"`
- Text string `json:"text"`
- ImageKey string `json:"image_key,omitempty"`
- ImageKeys []string `json:"image_keys,omitempty"`
- TopicTag string `json:"topic_tag,omitempty"`
- ScheduledAt int64 `json:"scheduled_at"`
- Status string `json:"status"`
- MediaID string `json:"media_id,optional"`
- Permalink string `json:"permalink,optional"`
- PublishedAt int64 `json:"published_at,optional"`
- ErrorMessage string `json:"error_message,optional"`
- RetryCount int `json:"retry_count,omitempty"`
- LastAttemptAt int64 `json:"last_attempt_at,omitempty"`
- NextAttemptAt int64 `json:"next_attempt_at,omitempty"`
- MissedAt int64 `json:"missed_at,omitempty"`
- PausedReason string `json:"paused_reason,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type PublishQueueItemPath struct {
- ID string `path:"id" validate:"required"`
- QID string `path:"qid" validate:"required"`
-}
-
-type PublishQueueListData struct {
- Pagination PaginationData `json:"pagination"`
- List []PublishQueueItemData `json:"list"`
-}
-
-type PublishQueueRangeHandlerReq struct {
- ThreadsAccountPath
- PublishQueueRangeQuery
-}
-
-type PublishQueueRangeQuery struct {
- StartAt int64 `form:"startAt,optional"`
- EndAt int64 `form:"endAt,optional"`
- Status string `form:"status,optional"`
-}
-
-type PublishSlotData struct {
- Weekday int `json:"weekday"`
- Time string `json:"time"`
-}
-
-type PublishSlotInsightData struct {
- Weekday int `json:"weekday"`
- Time string `json:"time"`
- AvgLikes1h int `json:"avg_likes_1h"`
- AvgReplies1h int `json:"avg_replies_1h"`
- AvgLikes24h int `json:"avg_likes_24h"`
- AvgReplies24h int `json:"avg_replies_24h"`
- AvgLikes7d int `json:"avg_likes_7d"`
- AvgReplies7d int `json:"avg_replies_7d"`
- SampleSize int `json:"sample_size"`
- Recommendation string `json:"recommendation"`
-}
-
-type PublishSlotInsightsData struct {
- AccountID string `json:"account_id"`
- Timezone string `json:"timezone"`
- Slots []PublishSlotInsightData `json:"slots"`
-}
-
-type PublishSourceRefData struct {
- Type string `json:"type"`
- PersonaID string `json:"persona_id,omitempty"`
- CopyMissionID string `json:"copy_mission_id,omitempty"`
- ScanPostID string `json:"scan_post_id,omitempty"`
- ManualSeed string `json:"manual_seed,omitempty"`
-}
-
-type ResearchItemData struct {
- Title string `json:"title,omitempty"`
- URL string `json:"url,omitempty"`
- Snippet string `json:"snippet,omitempty"`
- Query string `json:"query,omitempty"`
-}
-
-type ResearchMapData struct {
- AudienceSummary string `json:"audience_summary,omitempty"`
- ContentGoal string `json:"content_goal,omitempty"`
- Questions []string `json:"questions,omitempty"`
- Pillars []string `json:"pillars,omitempty"`
- Exclusions []string `json:"exclusions,omitempty"`
- ResearchItems []ResearchItemData `json:"research_items,omitempty"`
- ExpandStrategy string `json:"expand_strategy,omitempty"`
- PatrolKeywords []string `json:"patrol_keywords,omitempty"`
-}
-
-type RunThreadsAPIPlaygroundHandlerReq struct {
- ThreadsAccountPath
- RunThreadsAPIPlaygroundReq
-}
-
-type RunThreadsAPIPlaygroundReq struct {
- Action string `json:"action" validate:"required"`
- Query string `json:"query,optional"`
- Username string `json:"username,optional"`
- MediaID string `json:"media_id,optional"`
- ReplyID string `json:"reply_id,optional"`
- ReplyToID string `json:"reply_to_id,optional"`
- Text string `json:"text,optional"`
- ImageKey string `json:"image_key,optional"`
- ImageKeys []string `json:"image_keys,optional"`
- Permalink string `json:"permalink,optional"`
- HintText string `json:"hint_text,optional"`
- SkipPrime bool `json:"skip_prime,optional"`
- LocationQuery string `json:"location_query,optional"`
- Limit int `json:"limit,optional"`
- Hide bool `json:"hide,optional"`
- SearchType string `json:"search_type,optional"`
-}
-
-type RunThreadsAPISmokeTestData struct {
- List []ThreadsAPISmokeTestItem `json:"list"`
- Passed int `json:"passed"`
- Failed int `json:"failed"`
- Skipped int `json:"skipped"`
- Total int `json:"total"`
-}
-
-type ScanPostData struct {
- ID string `json:"id"`
- GraphNodeID string `json:"graph_node_id"`
- SearchTag string `json:"search_tag"`
- QueryDimension string `json:"query_dimension"`
- ExternalID string `json:"external_id"`
- Permalink string `json:"permalink"`
- Author string `json:"author"`
- Text string `json:"text"`
- Priority string `json:"priority"`
- PlacementScore int `json:"placement_score"`
- ProductFitScore int `json:"product_fit_score"`
- SolvedByProduct bool `json:"solved_by_product"`
- Source string `json:"source"`
- ScanJobID string `json:"scan_job_id"`
- OutreachStatus string `json:"outreach_status,omitempty"`
- PublishedReplyID string `json:"published_reply_id,omitempty"`
- PublishedPermalink string `json:"published_permalink,omitempty"`
- OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
- PostedAt string `json:"posted_at,omitempty"`
- RecencyDays int `json:"recency_days,omitempty"`
- Replies []ScanReplyData `json:"replies,omitempty"`
- LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type ScanReplyData struct {
- ExternalID string `json:"external_id,omitempty"`
- Author string `json:"author,omitempty"`
- Text string `json:"text"`
- Permalink string `json:"permalink,omitempty"`
- LikeCount int `json:"like_count,omitempty"`
- PostedAt string `json:"posted_at,omitempty"`
-}
-
-type ScheduleCopyDraftsData struct {
- Scheduled int `json:"scheduled"`
- List []PublishQueueItemData `json:"list"`
- Message string `json:"message"`
-}
-
-type ScheduleCopyDraftsReq struct {
- AccountID string `json:"account_id" validate:"required"`
- DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
- StartAt int64 `json:"start_at,optional"`
- Timezone string `json:"timezone,optional"`
- Slots []PublishSlotData `json:"slots,optional"`
- Mode string `json:"mode,optional"`
-}
-
-type ScheduleCopyMissionDraftsHandlerReq struct {
- CopyMissionPath
- CopyMissionScheduleCopyDraftsReq
-}
-
-type SchedulePersonaCopyDraftHandlerReq struct {
- CopyDraftPath
- SchedulePersonaCopyDraftReq
-}
-
-type SchedulePersonaCopyDraftReq struct {
- AccountID string `json:"account_id" validate:"required"`
- TopicTag string `json:"topic_tag,optional"`
- ScheduledAt int64 `json:"scheduled_at,optional"`
-}
-
-type SchedulePersonaDraftsHandlerReq struct {
- PersonaPath
- ScheduleCopyDraftsReq
-}
-
-type SearchContentFormulaPostsData struct {
- List []ContentFormulaSearchPostData `json:"list"`
- Message string `json:"message"`
-}
-
-type SearchContentFormulaPostsHandlerReq struct {
- ThreadsAccountPath
- SearchContentFormulaPostsReq
-}
-
-type SearchContentFormulaPostsReq struct {
- Keyword string `json:"keyword" validate:"required"`
- SearchType string `json:"search_type,optional"`
- Limit int `json:"limit,optional"`
-}
-
-type SettingData struct {
- ID string `json:"id"`
- Scope string `json:"scope"`
- ScopeID string `json:"scope_id"`
- Key string `json:"key"`
- Value map[string]interface{} `json:"value"`
- Version int `json:"version"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type SettingKeyPath struct {
- Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
- ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID,例如 user_id、account_id 或 global
- Key string `path:"key" validate:"required"` // 設定 key,例如 ai.default
-}
-
-type SettingListData struct {
- Pagination PaginationData `json:"pagination"`
- List []SettingData `json:"list"`
-}
-
-type SettingPath struct {
- Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
- ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID,例如 user_id、account_id 或 global
- Page int64 `form:"page,optional"` // 頁碼,從 1 開始
- PageSize int64 `form:"pageSize,optional"` // 每頁筆數,server 會限制最大值
-}
-
-type SettingUpsertReq struct {
- Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
- ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID,例如 user_id、account_id 或 global
- Key string `path:"key" validate:"required"` // 設定 key,例如 ai.default
- Value map[string]interface{} `json:"value" validate:"required"` // 設定內容 JSON object
- Version int `json:"version,optional"` // schema version,未帶入時預設 1
-}
-
-type StartBrandScanJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartBrandScanJobHandlerReq struct {
- BrandPath
- StartBrandScanJobReq
-}
-
-type StartBrandScanJobReq struct {
- GraphID string `json:"graph_id,optional"`
- NodeIDs []string `json:"node_ids,optional"`
- DualTrack bool `json:"dual_track,optional"`
- PatrolMode bool `json:"patrol_mode,optional"`
- TestPatrol bool `json:"test_patrol,optional"`
- PatrolKeywords []string `json:"patrol_keywords,optional"`
-}
-
-type StartCopyMissionAnalyzeJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartCopyMissionCopyDraftJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartCopyMissionCopyDraftJobHandlerReq struct {
- CopyMissionPath
- StartCopyMissionCopyDraftJobReq
-}
-
-type StartCopyMissionCopyDraftJobReq struct {
- ScanPostID string `json:"scan_post_id" validate:"required"`
-}
-
-type StartCopyMissionMatrixJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartCopyMissionMatrixJobHandlerReq struct {
- CopyMissionPath
- StartCopyMissionMatrixJobReq
-}
-
-type StartCopyMissionMatrixJobReq struct {
- Count int `json:"count,optional"`
-}
-
-type StartCopyMissionScanJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartPersonaFormulaDraftJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- AiProvider string `json:"ai_provider"`
- AiModel string `json:"ai_model"`
- Message string `json:"message"`
-}
-
-type StartPersonaFormulaDraftJobHandlerReq struct {
- PersonaPath
- StartPersonaFormulaDraftJobReq
-}
-
-type StartPersonaFormulaDraftJobReq struct {
- AccountID string `json:"account_id" validate:"required"`
- FormulaID string `json:"formula_id" validate:"required"`
- Topic string `json:"topic" validate:"required"`
- Brief string `json:"brief,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
-}
-
-type StartPersonaRewriteDraftJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- AiProvider string `json:"ai_provider"`
- AiModel string `json:"ai_model"`
- Message string `json:"message"`
-}
-
-type StartPersonaRewriteDraftJobHandlerReq struct {
- PersonaPath
- StartPersonaRewriteDraftJobReq
-}
-
-type StartPersonaRewriteDraftJobReq struct {
- AccountID string `json:"account_id" validate:"required"`
- ReferenceText string `json:"reference_text" validate:"required"`
- Topic string `json:"topic" validate:"required"`
- Brief string `json:"brief,optional"`
- SaveLabel string `json:"save_label,optional"`
- UseWebSearch bool `json:"use_web_search,optional"`
- DraftCount int `json:"draft_count,optional"`
-}
-
-type StartPersonaStyleAnalysisData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartPersonaStyleAnalysisFromTextData struct {
- Persona PersonaData `json:"persona"`
- PostCount int `json:"post_count"`
- Message string `json:"message"`
-}
-
-type StartPersonaStyleAnalysisFromTextHandlerReq struct {
- PersonaPath
- StartPersonaStyleAnalysisFromTextReq
-}
-
-type StartPersonaStyleAnalysisFromTextReq struct {
- ReferenceTexts []string `json:"reference_texts,optional"`
- RawText string `json:"raw_text,optional"`
- SourceLabel string `json:"source_label,optional"`
-}
-
-type StartPersonaStyleAnalysisHandlerReq struct {
- PersonaPath
- StartPersonaStyleAnalysisReq
-}
-
-type StartPersonaStyleAnalysisReq struct {
- BenchmarkUsername string `json:"benchmark_username" validate:"required"`
-}
-
-type StartPersonaTopicMatrixJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- AiProvider string `json:"ai_provider"`
- AiModel string `json:"ai_model"`
- Message string `json:"message"`
-}
-
-type StartPersonaTopicMatrixJobHandlerReq struct {
- PersonaPath
- GeneratePersonaTopicMatrixReq
-}
-
-type StartPersonaViralScanJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartPersonaViralScanJobHandlerReq struct {
- PersonaPath
- StartPersonaViralScanJobReq
-}
-
-type StartPersonaViralScanJobReq struct {
- Keywords []string `json:"keywords,optional"`
-}
-
-type StartPlacementTopicOutreachDraftJobHandlerReq struct {
- PlacementTopicPath
- StartPlacementTopicOutreachDraftJobReq
-}
-
-type StartPlacementTopicOutreachDraftJobReq struct {
- ScanPostID string `json:"scan_post_id,optional"`
- ScanPostIDs []string `json:"scan_post_ids,optional"`
- Count int `json:"count,optional"`
- VoicePersonaID string `json:"voice_persona_id,optional"`
- ProductID string `json:"product_id,optional"`
- Regenerate bool `json:"regenerate,optional"`
-}
-
-type StartPlacementTopicOutreachDraftJobsData struct {
- Jobs []StartBrandScanJobData `json:"jobs"`
- Message string `json:"message"`
-}
-
-type StartPlacementTopicScanJobHandlerReq struct {
- PlacementTopicPath
- StartBrandScanJobReq
-}
-
-type StartPublishInventoryRefillJobData struct {
- JobID string `json:"job_id"`
- Status string `json:"status"`
- Message string `json:"message"`
-}
-
-type StartPublishInventoryRefillJobHandlerReq struct {
- ThreadsAccountPath
- StartPublishInventoryRefillJobReq
-}
-
-type StartPublishInventoryRefillJobReq struct {
- Count int `json:"count,optional"`
-}
-
-type StartThreadsOAuthData struct {
- AuthorizeURL string `json:"authorize_url"`
- State string `json:"state"`
- AccountID string `json:"account_id"`
-}
-
-type StartThreadsOAuthQuery struct {
- AccountID string `form:"account_id,optional"`
-}
-
-type Status struct {
- Code int64 `json:"code"`
- Message string `json:"message"`
- Data interface{} `json:"data,optional"`
- Error ErrorDetail `json:"error,optional"`
-}
-
-type StorePersonaStyleProfileData struct {
- ID string `json:"id"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type StorePersonaStyleProfileReq struct {
- ID string `path:"id" validate:"required"`
- TenantID string `json:"tenant_id" validate:"required"`
- OwnerUID string `json:"owner_uid" validate:"required"`
- StyleProfile string `json:"style_profile" validate:"required"`
- StyleBenchmark string `json:"style_benchmark,optional"`
-}
-
-type StylePresetData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Name string `json:"name"`
- Tone string `json:"tone,omitempty"`
- CTA []string `json:"cta,omitempty"`
- BannedWords []string `json:"banned_words,omitempty"`
- Notes string `json:"notes,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type StylePresetPath struct {
- ID string `path:"id" validate:"required"`
- PresetID string `path:"presetId" validate:"required"`
-}
-
-type SyncMentionInboxData struct {
- Synced int `json:"synced"`
- Ready int `json:"ready"`
- Total int64 `json:"total"`
-}
-
-type SyncMentionInboxHandlerReq struct {
- ThreadsAccountPath
- SyncMentionInboxReq
-}
-
-type SyncMentionInboxReq struct {
- Limit int `json:"limit,optional"`
- MaxPages int `json:"max_pages,optional"`
-}
-
-type ThreadsAPIPlaygroundData struct {
- Action string `json:"action"`
- Ok bool `json:"ok"`
- Message string `json:"message"`
- Data string `json:"data,optional"`
-}
-
-type ThreadsAPISmokeTestItem struct {
- Scope string `json:"scope"`
- Name string `json:"name"`
- Status string `json:"status"`
- Message string `json:"message"`
- Count int `json:"count"`
-}
-
-type ThreadsAccountAiProviderModelsData struct {
- ID string `json:"id"`
- Label string `json:"label"`
- Models []string `json:"models"`
- Streams bool `json:"streams"`
- Error string `json:"error,optional"`
-}
-
-type ThreadsAccountAiProviderModelsHandlerReq struct {
- ThreadsAccountAiProviderPath
- ListThreadsAccountAiProviderModelsReq
-}
-
-type ThreadsAccountAiProviderPath struct {
- ID string `path:"id" validate:"required"`
- Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
-}
-
-type ThreadsAccountAiSettingsData struct {
- AccountID string `json:"account_id"`
- Provider string `json:"provider"`
- Model string `json:"model"`
- ResearchProvider string `json:"research_provider,omitempty"`
- ResearchModel string `json:"research_model,omitempty"`
- ApiKeys map[string]string `json:"api_keys"`
- ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
-}
-
-type ThreadsAccountConnectionData struct {
- AccountID string `json:"account_id"`
- AccountName string `json:"account_name"`
- Username string `json:"username,omitempty"`
- BrowserConnected bool `json:"browser_connected"`
- ApiConnected bool `json:"api_connected"`
- Prefs ThreadsAccountConnectionPrefs `json:"prefs"`
-}
-
-type ThreadsAccountConnectionPrefs struct {
- SearchViaApi bool `json:"search_via_api"`
- SearchSourceMode string `json:"search_source_mode"`
- PublishViaApi bool `json:"publish_via_api"`
- DevMode bool `json:"dev_mode"`
- ScrapeReplies bool `json:"scrape_replies"`
- RepliesPerPost int `json:"replies_per_post"`
- PublishHeaded bool `json:"publish_headed"`
- PlaywrightDebug bool `json:"playwright_debug"`
-}
-
-type ThreadsAccountData struct {
- ID string `json:"id"`
- DisplayName string `json:"display_name,omitempty"`
- Username string `json:"username,omitempty"`
- ThreadsUserID string `json:"threads_user_id,omitempty"`
- PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
- BrowserConnected bool `json:"browser_connected"`
- ApiConnected bool `json:"api_connected"`
- APITokenExpiresAt int64 `json:"api_token_expires_at,omitempty"`
- Status string `json:"status"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type ThreadsAccountInsightsBlock struct {
- Status string `json:"status"`
- Message string `json:"message,optional"`
- Metrics []ThreadsInsightMetricItem `json:"metrics,optional"`
-}
-
-type ThreadsAccountPath struct {
- ID string `path:"id" validate:"required"`
-}
-
-type ThreadsDiagnosticsData struct {
- AccountID string `json:"account_id"`
- CheckedAt int64 `json:"checked_at"`
- ApiConnected bool `json:"api_connected"`
- TokenExpiresAt int64 `json:"token_expires_at,omitempty"`
- Scopes []string `json:"scopes"`
- Items []ThreadsAPISmokeTestItem `json:"items"`
- Message string `json:"message"`
-}
-
-type ThreadsInsightMetricItem struct {
- Name string `json:"name"`
- Value int `json:"value"`
-}
-
-type ThreadsOAuthCallbackQuery 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 ThreadsOAuthConfigData struct {
- RedirectURI string `json:"redirect_uri"`
- SuccessRedirect string `json:"success_redirect"`
- Enabled bool `json:"enabled"`
- RequiresHTTPS bool `json:"requires_https"`
-}
-
-type ThreadsOAuthLogItem struct {
- At int64 `json:"at"`
- Level string `json:"level"`
- Stage string `json:"stage"`
- AccountID string `json:"account_id,omitempty"`
- Message string `json:"message"`
-}
-
-type ThreadsPostMetricCapability struct {
- Scope string `json:"scope"`
- Name string `json:"name"`
- Metrics []string `json:"metrics"`
- Trackable bool `json:"trackable"`
- Note string `json:"note,optional"`
-}
-
-type ThreadsPostPerformanceData struct {
- AccountID string `json:"account_id"`
- Username string `json:"username,optional"`
- ThreadsUserID string `json:"threads_user_id,optional"`
- FetchedAt int64 `json:"fetched_at"`
- Capabilities []ThreadsPostMetricCapability `json:"capabilities"`
- AccountInsights ThreadsAccountInsightsBlock `json:"account_insights"`
- Posts []ThreadsPostPerformanceItem `json:"posts"`
-}
-
-type ThreadsPostPerformanceItem struct {
- MediaID string `json:"media_id"`
- Text string `json:"text,optional"`
- Permalink string `json:"permalink,optional"`
- Timestamp string `json:"timestamp,optional"`
- MediaType string `json:"media_type,optional"`
- MediaURL string `json:"media_url,optional"`
- ThumbnailURL string `json:"thumbnail_url,optional"`
- TopicTag string `json:"topic_tag,optional"`
- Shortcode string `json:"shortcode,optional"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Views int `json:"views,optional"`
- Shares int `json:"shares,optional"`
- InsightsStatus string `json:"insights_status,optional"`
- InsightsMessage string `json:"insights_message,optional"`
-}
-
-type ThreadsPublishHealthCheckpoint struct {
- Checkpoint string `json:"checkpoint"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count"`
- QuoteCount int `json:"quote_count"`
- Views int `json:"views,optional"`
- CheckedAt int64 `json:"checked_at"`
-}
-
-type ThreadsPublishHealthData struct {
- Summary ThreadsPublishHealthSummary `json:"summary"`
- Pagination PaginationData `json:"pagination"`
- List []ThreadsPublishHealthItem `json:"list"`
-}
-
-type ThreadsPublishHealthItem struct {
- MediaID string `json:"media_id"`
- Permalink string `json:"permalink,optional"`
- Text string `json:"text,optional"`
- PublishedAt int64 `json:"published_at"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- RepostCount int `json:"repost_count,optional"`
- QuoteCount int `json:"quote_count,optional"`
- Views int `json:"views,optional"`
- Shares int `json:"shares,optional"`
- Checkpoints []ThreadsPublishHealthCheckpoint `json:"checkpoints"`
-}
-
-type ThreadsPublishHealthSummary struct {
- PendingScheduled int64 `json:"pending_scheduled"`
- FailedCount int64 `json:"failed_count"`
- Published7d int64 `json:"published_7d"`
- TotalLikes7d int `json:"total_likes_7d"`
- TotalReplies7d int `json:"total_replies_7d"`
-}
-
-type TopicCandidateData struct {
- ID string `json:"id"`
- PersonaID string `json:"persona_id"`
- Name string `json:"name"`
- Source string `json:"source,omitempty"`
- Category string `json:"category,omitempty"`
- SeedQuery string `json:"seed_query,omitempty"`
- TrendReason string `json:"trend_reason,omitempty"`
- TrendKeywords []string `json:"trend_keywords,omitempty"`
- HeatScore int `json:"heat_score,omitempty"`
- FitScore int `json:"fit_score,omitempty"`
- InteractionScore int `json:"interaction_score,omitempty"`
- ExtendScore int `json:"extend_score,omitempty"`
- RiskScore int `json:"risk_score,omitempty"`
- FinalScore float64 `json:"final_score,omitempty"`
- RecommendedMissions []string `json:"recommended_missions,omitempty"`
- Status string `json:"status,omitempty"`
- CreateAt int64 `json:"create_at"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type UpdateBrandHandlerReq struct {
- BrandPath
- UpdateBrandReq
-}
-
-type UpdateBrandProductHandlerReq struct {
- BrandProductPath
- UpdateBrandProductReq
-}
-
-type UpdateBrandProductReq struct {
- Label *string `json:"label,optional"`
- ProductContext *string `json:"product_context,optional"`
- PlacementURL string `json:"placement_url,optional"`
- MatchTags []string `json:"match_tags,optional"`
-}
-
-type UpdateBrandReq struct {
- DisplayName *string `json:"display_name,optional"`
- TopicName *string `json:"topic_name,optional"`
- SeedQuery *string `json:"seed_query,optional"`
- Brief *string `json:"brief,optional"`
- ProductBrief *string `json:"product_brief,optional"`
- ProductContext *string `json:"product_context,optional"`
- ProductID *string `json:"product_id,optional"`
- TargetAudience *string `json:"target_audience,optional"`
- Goals *string `json:"goals,optional"`
- AudienceSummary *string `json:"audience_summary,optional"`
- ContentGoal *string `json:"content_goal,optional"`
- Questions []string `json:"questions,optional"`
- Pillars []string `json:"pillars,optional"`
- Exclusions []string `json:"exclusions,optional"`
- PatrolKeywords []string `json:"patrol_keywords,optional"`
-}
-
-type UpdateContentPlanHandlerReq struct {
- ContentPlanPath
- UpdateContentPlanReq
-}
-
-type UpdateContentPlanReq struct {
- Topic *string `json:"topic,optional"`
- Mission *string `json:"mission,optional"`
- TargetAudience *string `json:"target_audience,optional"`
- Angle *string `json:"angle,optional"`
- OpeningType *string `json:"opening_type,optional"`
- BodyType *string `json:"body_type,optional"`
- Emotion *string `json:"emotion,optional"`
- EndingType *string `json:"ending_type,optional"`
- CtaType *string `json:"cta_type,optional"`
- RiskLevel *string `json:"risk_level,optional"`
- RequiresHumanReview *bool `json:"requires_human_review,optional"`
- Avoid []string `json:"avoid,optional"`
- SelectedKnowledge []string `json:"selected_knowledge,optional"`
- Status *string `json:"status,optional"`
-}
-
-type UpdateCopyDraftHandlerReq struct {
- CopyDraftPath
- UpdateCopyDraftReq
-}
-
-type UpdateCopyDraftReq struct {
- Text *string `json:"text,optional"`
- TopicTag *string `json:"topic_tag,optional"`
- Hook *string `json:"hook,optional"`
- Angle *string `json:"angle,optional"`
- Rationale *string `json:"rationale,optional"`
- Status *string `json:"status,optional"`
-}
-
-type UpdateCopyMissionHandlerReq struct {
- CopyMissionPath
- UpdateCopyMissionReq
-}
-
-type UpdateCopyMissionReq struct {
- Label *string `json:"label,optional"`
- SeedQuery *string `json:"seed_query,optional"`
- Brief *string `json:"brief,optional"`
- AudienceSummary *string `json:"audience_summary,optional"`
- ContentGoal *string `json:"content_goal,optional"`
- Questions []string `json:"questions,optional"`
- Pillars []string `json:"pillars,optional"`
- Exclusions []string `json:"exclusions,optional"`
- KnowledgeItems []string `json:"knowledge_items,optional"`
- SelectedKnowledgeItems []string `json:"selected_knowledge_items,optional"`
- BenchmarkNotes *string `json:"benchmark_notes,optional"`
- SelectedTags []string `json:"selected_tags,optional"`
- Status *string `json:"status,optional"`
-}
-
-type UpdateJobScheduleReq struct {
- ID string `path:"id" validate:"required"` // schedule id
- Cron string `json:"cron,optional"` // cron expression
- Timezone string `json:"timezone,optional"` // timezone
- PayloadTemplate map[string]interface{} `json:"payload_template,optional"` // payload template
- Enabled *bool `json:"enabled,optional"` // enabled flag
-}
-
-type UpdateMemberAiSettingsReq struct {
- Provider *string `json:"provider,optional"`
- Model *string `json:"model,optional"`
- ResearchProvider *string `json:"research_provider,optional"`
- ResearchModel *string `json:"research_model,optional"`
- ApiKeys map[string]string `json:"api_keys,optional"`
-}
-
-type UpdateMemberMeReq struct {
- DisplayName string `json:"display_name,optional"`
- Avatar string `json:"avatar,optional"`
- Language string `json:"language,optional"`
- Currency string `json:"currency,optional"`
- Phone string `json:"phone,optional"`
-}
-
-type UpdateMemberPasswordReq struct {
- UID string `path:"uid" validate:"required"`
- Password string `json:"password" validate:"required,min=8,max=128"`
-}
-
-type UpdateMemberPlacementSettingsReq struct {
- WebSearchProvider *string `json:"web_search_provider,optional"`
- BraveAPIKey *string `json:"brave_api_key,optional"`
- ExaAPIKey *string `json:"exa_api_key,optional"`
- BraveCountry *string `json:"brave_country,optional"`
- BraveSearchLang *string `json:"brave_search_lang,optional"`
- ExaUserLocation *string `json:"exa_user_location,optional"`
- ExpandStrategy *string `json:"expand_strategy,optional"`
- DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
-}
-
-type UpdateMemberProfileReq struct {
- UID string `path:"uid" validate:"required"`
- DisplayName *string `json:"display_name,optional"`
- Avatar *string `json:"avatar,optional"`
- Language *string `json:"language,optional"`
- Currency *string `json:"currency,optional"`
- Phone *string `json:"phone,optional"`
- Status *string `json:"status,optional"`
- BusinessEmail *string `json:"business_email,optional"`
- BusinessEmailVerified *bool `json:"business_email_verified,optional"`
- BusinessPhone *string `json:"business_phone,optional"`
- BusinessPhoneVerified *bool `json:"business_phone_verified,optional"`
- EmailVerified *bool `json:"email_verified,optional"`
-}
-
-type UpdateMemberRolesReq struct {
- UID string `path:"uid" validate:"required"`
- Roles []string `json:"roles" validate:"required"`
-}
-
-type UpdateOutreachDraftHandlerReq struct {
- BrandPath
- DraftID string `path:"draftId" validate:"required"`
- UpdateOutreachDraftReq
-}
-
-type UpdateOutreachDraftReq struct {
- DraftIndex int `json:"draft_index"`
- Text string `json:"text" validate:"required"`
-}
-
-type UpdatePersonaHandlerReq struct {
- PersonaPath
- UpdatePersonaReq
-}
-
-type UpdatePersonaReq struct {
- DisplayName *string `json:"display_name,optional"`
- Persona *string `json:"persona,optional"`
- Brief *string `json:"brief,optional"`
- StyleProfile *string `json:"style_profile,optional"`
- StyleBenchmark *string `json:"style_benchmark,optional"`
-}
-
-type UpdatePlacementTopicHandlerReq struct {
- PlacementTopicPath
- UpdatePlacementTopicReq
-}
-
-type UpdatePlacementTopicOutreachDraftHandlerReq struct {
- PlacementTopicPath
- DraftID string `path:"draftId" validate:"required"`
- UpdateOutreachDraftReq
-}
-
-type UpdatePlacementTopicReq struct {
- BrandID *string `json:"brand_id,optional"`
- TopicName *string `json:"topic_name,optional"`
- SeedQuery *string `json:"seed_query,optional"`
- Brief *string `json:"brief,optional"`
- ProductID *string `json:"product_id,optional"`
- AudienceSummary *string `json:"audience_summary,optional"`
- ContentGoal *string `json:"content_goal,optional"`
- Questions []string `json:"questions,optional"`
- Pillars []string `json:"pillars,optional"`
- Exclusions []string `json:"exclusions,optional"`
- PatrolKeywords []string `json:"patrol_keywords,optional"`
-}
-
-type UpdateThreadsAccountAiSettingsHandlerReq struct {
- ThreadsAccountPath
- UpdateThreadsAccountAiSettingsReq
-}
-
-type UpdateThreadsAccountAiSettingsReq struct {
- Provider *string `json:"provider,optional"`
- Model *string `json:"model,optional"`
- ResearchProvider *string `json:"research_provider,optional"`
- ResearchModel *string `json:"research_model,optional"`
- ApiKeys map[string]string `json:"api_keys,optional"`
-}
-
-type UpdateThreadsAccountConnectionHandlerReq struct {
- ThreadsAccountPath
- UpdateThreadsAccountConnectionReq
-}
-
-type UpdateThreadsAccountConnectionReq struct {
- SearchViaApi *bool `json:"search_via_api,optional"`
- SearchSourceMode *string `json:"search_source_mode,optional"`
- PublishViaApi *bool `json:"publish_via_api,optional"`
- DevMode *bool `json:"dev_mode,optional"`
- ScrapeReplies *bool `json:"scrape_replies,optional"`
- RepliesPerPost *int `json:"replies_per_post,optional"`
- PublishHeaded *bool `json:"publish_headed,optional"`
- PlaywrightDebug *bool `json:"playwright_debug,optional"`
-}
-
-type UpdateThreadsAccountHandlerReq struct {
- ThreadsAccountPath
- UpdateThreadsAccountReq
-}
-
-type UpdateThreadsAccountReq struct {
- DisplayName *string `json:"display_name,optional"`
- PersonaID *string `json:"persona_id,optional"` // deprecated: use persona_id in publish payload instead
-}
-
-type UploadAvatarData struct {
- Avatar string `json:"avatar"`
-}
-
-type UploadPublishAttachmentData struct {
- AttachmentKey string `json:"attachment_key"`
- PublicURL string `json:"public_url"`
-}
-
-type UpsertBrandScanScheduleHandlerReq struct {
- BrandPath
- UpsertBrandScanScheduleReq
-}
-
-type UpsertBrandScanScheduleReq struct {
- Cron string `json:"cron,optional"`
- Timezone string `json:"timezone,optional"`
- Enabled bool `json:"enabled"`
-}
-
-type UpsertCopyMissionScanScheduleHandlerReq struct {
- CopyMissionPath
- UpsertCopyMissionScanScheduleReq
-}
-
-type UpsertCopyMissionScanScheduleReq struct {
- Cron string `json:"cron,optional"`
- Timezone string `json:"timezone,optional"`
- Enabled bool `json:"enabled"`
-}
-
-type UpsertJobTemplateReq struct {
- Type string `path:"type" validate:"required"` // template type
- Version int `json:"version,optional"` // template version
- Name string `json:"name" validate:"required"` // display name
- Description string `json:"description,optional"` // description
- Enabled bool `json:"enabled"` // enabled flag
- Repeatable bool `json:"repeatable"` // repeatable flag
- ConcurrencyPolicy string `json:"concurrency_policy,optional"` // concurrency policy
- DedupeKeys []string `json:"dedupe_keys,optional"` // dedupe keys
- TimeoutSeconds int `json:"timeout_seconds,optional"` // timeout seconds
- CancelPolicy JobCancelPolicyData `json:"cancel_policy,optional"` // cancel policy
- RetryPolicy JobRetryPolicyData `json:"retry_policy,optional"` // retry policy
- Steps []JobTemplateStepData `json:"steps" validate:"required,min=1,dive"` // steps
-}
-
-type UpsertPlacementTopicScanScheduleHandlerReq struct {
- PlacementTopicPath
- UpsertBrandScanScheduleReq
-}
-
-type UpsertPublishGuardPolicyHandlerReq struct {
- ThreadsAccountPath
- UpsertPublishGuardPolicyReq
-}
-
-type UpsertPublishGuardPolicyReq struct {
- Enabled bool `json:"enabled"`
- MaxDailyPosts int `json:"max_daily_posts,optional"`
- MinIntervalMinutes int `json:"min_interval_minutes,optional"`
- ConsecutiveFailurePauseLimit int `json:"consecutive_failure_pause_limit,optional"`
- Paused *bool `json:"paused,optional"`
- PausedReason string `json:"paused_reason,optional"`
-}
-
-type UpsertPublishInventoryPolicyHandlerReq struct {
- ThreadsAccountPath
- UpsertPublishInventoryPolicyReq
-}
-
-type UpsertPublishInventoryPolicyReq struct {
- Enabled bool `json:"enabled"`
- TargetDailyCount int `json:"target_daily_count,optional"`
- LowStockThreshold int `json:"low_stock_threshold,optional"`
- LookaheadDays int `json:"lookahead_days,optional"`
- Timezone string `json:"timezone,optional"`
- Slots []PublishSlotData `json:"slots,optional"`
- SourceRefs []PublishSourceRefData `json:"source_refs,optional"`
-}
-
-type UpsertStylePresetHandlerReq struct {
- StylePresetPath
- UpsertStylePresetReq
-}
-
-type UpsertStylePresetReq struct {
- Name string `json:"name" validate:"required"`
- Tone string `json:"tone,optional"`
- CTA []string `json:"cta,optional"`
- BannedWords []string `json:"banned_words,optional"`
- Notes string `json:"notes,optional"`
- Apply bool `json:"apply,optional"`
-}
-
-type ViralScanPostData struct {
- ID string `json:"id"`
- SearchTag string `json:"search_tag"`
- Permalink string `json:"permalink"`
- Author string `json:"author"`
- AuthorVerified bool `json:"author_verified,omitempty"`
- FollowerCount int `json:"follower_count,omitempty"`
- Text string `json:"text"`
- LikeCount int `json:"like_count"`
- ReplyCount int `json:"reply_count"`
- EngagementScore int `json:"engagement_score"`
- Source string `json:"source"`
- ScanJobID string `json:"scan_job_id"`
- Replies []ScanReplyData `json:"replies,omitempty"`
- CreateAt int64 `json:"create_at"`
-}
-
-type WorkerCancelCheckData struct {
- Cancelled bool `json:"cancelled"`
-}
-
-type WorkerCompleteReq struct {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- Result map[string]interface{} `json:"result,optional"`
-}
-
-type WorkerFailReq struct {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- Error string `json:"error" validate:"required"`
- Phase string `json:"phase,optional"`
-}
-
-type WorkerHeartbeatReq struct {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- TTLSeconds int `json:"ttl_seconds,optional"`
-}
-
-type WorkerJobPath struct {
- ID string `path:"id" validate:"required"`
-}
-
-type WorkerJobReq struct {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
-}
-
-type WorkerOKData struct {
- OK bool `json:"ok"`
-}
-
-type WorkerProgressReq struct {
- WorkerJobPath
- WorkerID string `json:"worker_id" validate:"required"`
- Phase string `json:"phase,optional"`
- Summary string `json:"summary,optional"`
- Percentage int `json:"percentage,optional"`
- Steps []JobStepProgressData `json:"steps,optional"`
-}
-
-type WorkerThreadsAccountSessionData struct {
- AccountID string `json:"account_id"`
- StorageState string `json:"storage_state"`
- UpdateAt int64 `json:"update_at"`
-}
-
-type WorkerThreadsAccountSessionReq struct {
- ID string `path:"id" validate:"required"`
- TenantID string `json:"tenant_id" validate:"required"`
- OwnerUID string `json:"owner_uid" validate:"required"`
-}
diff --git a/old/backend/internal/worker/job/crawler_search.go b/old/backend/internal/worker/job/crawler_search.go
deleted file mode 100644
index 852fe3a..0000000
--- a/old/backend/internal/worker/job/crawler_search.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package job
-
-import (
- "context"
- "strings"
-
- "haixun-backend/internal/library/placement"
-)
-
-func makeCrawlerSearchFn(deps ScanPlacementDeps, tenantID, ownerUID string) placement.CrawlerSearchFn {
- return func(ctx context.Context, member placement.MemberContext, keyword string, limit int) ([]placement.DiscoverPost, error) {
- accountID := strings.TrimSpace(member.ActiveAccountID)
- if accountID == "" {
- return nil, placementErr("請先選定經營帳號並同步 Chrome Session")
- }
- session, err := deps.ThreadsAccount.GetBrowserSession(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return nil, err
- }
- return placement.RunExecCrawlerSearch(ctx, session.StorageState, keyword, limit)
- }
-}
-
-func placementErr(msg string) error {
- return &placementError{msg: msg}
-}
-
-type placementError struct {
- msg string
-}
-
-func (e *placementError) Error() string {
- return e.msg
-}
diff --git a/old/backend/internal/worker/job/expand_graph.go b/old/backend/internal/worker/job/expand_graph.go
deleted file mode 100644
index 31b5138..0000000
--- a/old/backend/internal/worker/job/expand_graph.go
+++ /dev/null
@@ -1,886 +0,0 @@
-package job
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
- "sync"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- libprompt "haixun-backend/internal/library/prompt"
- "haixun-backend/internal/library/websearch"
- "haixun-backend/internal/model/ai/domain/enum"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- brandentity "haixun-backend/internal/model/brand/domain/entity"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type ExpandGraphDeps struct {
- Jobs jobdom.UseCase
- Brand branddomain.UseCase
- PlacementTopic topicdomain.UseCase
- KnowledgeGraph kgusecase.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterExpandGraphHandler(runner *Runner, deps ExpandGraphDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("expand", func(ctx context.Context, step StepContext) error {
- return runExpandGraph(ctx, step, deps)
- })
-}
-
-func brandIDFromPayload(payload map[string]any) string {
- brandID := stringField(payload, "brand_id")
- if brandID == "" {
- brandID = stringField(payload, "persona_id")
- }
- return brandID
-}
-
-func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) error {
- payload := step.Run.Payload
- tenantID := stringField(payload, "tenant_id")
- ownerUID := stringField(payload, "owner_uid")
- seed := stringField(payload, "seed_query")
- supplemental := boolField(payload, "supplemental")
-
- if tenantID == "" || ownerUID == "" {
- return fmt.Errorf("expand-graph payload missing tenant_id or owner_uid")
- }
- if seed == "" {
- return fmt.Errorf("expand-graph payload missing seed_query")
- }
-
- scope, err := resolvePlacementScope(ctx, deps.Brand, deps.PlacementTopic, tenantID, ownerUID, payload)
- if err != nil {
- return err
- }
- brand := scope.Brand
- brandID := scope.CatalogBrand
- if brandID == "" {
- return fmt.Errorf("expand-graph payload missing brand_id or topic_id")
- }
-
- productBrief := strings.TrimSpace(brand.ProductBrief)
- if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
- productBrief = formatted
- }
-
- research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
- if err != nil {
- return err
- }
- webClient := websearch.New(memberCtx.WebSearchConfig())
-
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return err
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "expand",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- bootstrap := boolField(payload, "bootstrap")
- regenerateMap := boolField(payload, "regenerate_map")
- expandStrategy := libkg.ParseExpandStrategy(stringField(payload, "expand_strategy"))
- needResearchMap := bootstrap || regenerateMap || brand.ResearchMap.IsEmpty()
- prefetchedBrave := []libkg.BraveSource{}
- var prefetchQueries []string
-
- if needResearchMap && expandStrategy.RequiresWebSearch() {
- updateProgress("平行產生研究地圖與蒐集參考資料…", 5)
- var mapErr error
- var wg sync.WaitGroup
- wg.Add(2)
-
- go func() {
- defer wg.Done()
- mapErr = ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress)
- }()
-
- go func() {
- defer wg.Done()
- prefetchPlan := kgPlanInput(brand, seed, productBrief, nil, false, expandStrategy)
- prefetchQueries = libkg.PlanBootstrapQueries(prefetchPlan)
- if len(prefetchQueries) == 0 {
- return
- }
- sources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, prefetchQueries, expandStrategy, func(i, total int) {
- pct := 8 + ((i + 1) * 12 / max(total, 1))
- updateProgress(fmt.Sprintf("預先蒐集參考資料 %d/%d…", i+1, total), pct)
- }, func() error {
- cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
- if cancelled {
- return errJobCancelled
- }
- return ctx.Err()
- })
- if err == nil {
- prefetchedBrave = sources
- return
- }
- prefetchQueries = nil
- }()
-
- wg.Wait()
- if mapErr != nil {
- return mapErr
- }
- brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
- if err != nil {
- return err
- }
- } else if needResearchMap {
- updateProgress("產生研究地圖…", 5)
- if err := ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress); err != nil {
- return err
- }
- brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
- if err != nil {
- return err
- }
- }
-
- var existing *kgusecase.GraphSummary
- if supplemental {
- if scope.TopicID != "" {
- existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID)
- } else {
- existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
- }
- } else if scope.TopicID != "" {
- existing, _ = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, scope.TopicID, brandID)
- } else {
- existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
- }
-
- braveSources := []libkg.BraveSource{}
- var systemPrompt string
- var userPrompt string
-
- switch expandStrategy {
- case libkg.ExpandStrategyLLM:
- updateProgress("整理延伸知識…", 20)
- systemPrompt, err = libprompt.KnowledgeGraphLLMSystem()
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed")
- }
- topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief)
- kgVars := topicCtx.PromptLines()
- kgVars["seed"] = seed
- kgVars["product_brief_line"] = libkg.OptionalPromptLine("產品簡述", productBrief)
- kgVars["target_audience_line"] = libkg.OptionalPromptLine("目標受眾", brand.TargetAudience)
- kgVars["persona_line"] = libkg.OptionalPromptLine("主題目標", brand.Brief)
- kgVars["research_pillars_line"] = libkg.BulletPromptLine(
- "內容支柱(延伸知識要往這些方向廣泛展開)", brand.ResearchMap.Pillars)
- kgVars["research_questions_line"] = libkg.BulletPromptLine(
- "受眾提問方向(可衍生成更多周邊節點)", brand.ResearchMap.Questions)
- userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars)
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed")
- }
- default:
- updateProgress("蒐集參考資料…", 10)
-
- l1Labels := []string{}
- if existing != nil {
- l1Labels = libkg.L1LabelsFromNodes(existing.Nodes)
- }
- planIn := kgPlanInput(brand, seed, productBrief, l1Labels, supplemental, expandStrategy)
- queries := libkg.PlanQueries(planIn)
- if len(prefetchedBrave) > 0 {
- queries = libkg.QueriesExcept(queries, prefetchQueries)
- }
-
- updateProgress(fmt.Sprintf("蒐集參考資料(%d 項查詢)…", len(queries)+len(prefetchQueries)), 25)
-
- var moreBrave []libkg.BraveSource
- if len(queries) > 0 {
- moreBrave, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) {
- pct := 25 + ((i + 1) * 30 / max(total, 1))
- updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", len(prefetchQueries)+i+1, len(prefetchQueries)+total), pct)
- }, func() error {
- cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
- if cancelled {
- return errJobCancelled
- }
- return ctx.Err()
- })
- if err != nil {
- return err
- }
- }
- braveSources = libkg.MergeBraveSources(prefetchedBrave, moreBrave)
- if len(braveSources) == 0 {
- return app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試")
- }
-
- updateProgress("整理延伸知識…", 60)
-
- systemPrompt, err = libprompt.KnowledgeGraphSystem()
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph prompt load failed")
- }
- userPrompt, err = libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources))
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph user prompt load failed")
- }
- }
- genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- },
- })
- result, err := deps.AI.GenerateText(ctx, genReq)
- if err != nil {
- return err
- }
-
- synthIn := libkg.SynthInput{
- Seed: seed,
- ProductBrief: productBrief,
- TargetAudience: brand.TargetAudience,
- }
- graph, err := libkg.ParseSynthOutput(result.Text, synthIn, braveSources)
- if err != nil {
- // The model occasionally replies with reasoning/prose and no JSON
- // object. Retry once, explicitly demanding a raw JSON object.
- jsonOnlyReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: libkg.KnowledgeGraphJSONOnlyRetryPrompt()},
- },
- })
- retryResult, retryErr := deps.AI.GenerateText(ctx, jsonOnlyReq)
- if retryErr != nil {
- return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + err.Error())
- }
- retryGraph, retryParseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources)
- if retryParseErr != nil {
- return app.For(code.AI).SvcThirdParty("延伸知識產生失敗,請重試:" + retryParseErr.Error())
- }
- graph = retryGraph
- result = retryResult
- }
- if libkg.GraphTooThin(graph) {
- retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: userPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()},
- },
- })
- if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil {
- if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, synthIn, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) {
- graph = retryGraph
- }
- }
- }
-
- if supplemental && existing != nil {
- graph = mergeGraphs(existing, graph, braveSources)
- }
-
- needsBreadthExpand := !supplemental &&
- (libkg.GraphNeedsBreadth(graph) || graph.PainTagCount < libkg.MinPainTagCandidates())
- if needsBreadthExpand {
- updateProgress(fmt.Sprintf("擴充延伸知識廣度(目前 %d 節點)…", len(graph.Nodes)), 75)
- planIn := kgPlanInput(brand, seed, productBrief, libkg.L1LabelsFromNodes(graph.Nodes), true, expandStrategy)
- if expandStrategy.UsesSupplementalBrave() {
- suppQueries := libkg.PlanQueries(planIn)
- extraSources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, suppQueries, expandStrategy, nil, func() error {
- cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
- if cancelled {
- return errJobCancelled
- }
- return ctx.Err()
- })
- if err != nil {
- return err
- }
- braveSources = append(braveSources, extraSources...)
- }
- suppInstruction, err := libprompt.KnowledgeGraphSupplemental()
- if err != nil {
- return app.For(code.AI).SysInternal("knowledge graph supplemental prompt load failed")
- }
- suppUserPrompt, err := libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources))
- if err != nil {
- return err
- }
- breadthPrompt := libkg.KnowledgeGraphBreadthUserPrompt(len(graph.Nodes))
- suppResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: systemPrompt,
- Messages: []domai.Message{
- {Role: "user", Content: suppUserPrompt + "\n\n" + suppInstruction + "\n\n" + breadthPrompt},
- },
- }))
- if err == nil {
- if patched, parseErr := libkg.ParseSynthOutput(suppResult.Text, libkg.SynthInput{Seed: seed}, braveSources); parseErr == nil {
- graph = mergeGraphs(&kgusecase.GraphSummary{
- Seed: graph.Seed,
- Nodes: graph.Nodes,
- Edges: graph.Edges,
- }, patched, braveSources)
- }
- }
- }
-
- if libkg.GraphNeedsBootstrap(graph) {
- updateProgress(fmt.Sprintf("從研究地圖補齊延伸知識(目前 %d 節點)…", len(graph.Nodes)), 85)
- libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions)
- }
- patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
- libkg.RescoreGraphIntentFit(&graph, patrolInput)
- libkg.DeriveSearchTagsFromGraph(&graph, patrolInput)
- if existing != nil && len(existing.Nodes) > 0 {
- libkg.PreserveNodeSelectionByLabel(graph.Nodes, existing.Nodes)
- }
-
- updateProgress("整理海巡關鍵字…", 88)
- if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil {
- return err
- }
- if scope.TopicID != "" {
- topic, topicErr := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID)
- if topicErr == nil && topic != nil {
- brand = placementTopicAsBrand(scope, topic)
- }
- } else {
- brand, err = deps.Brand.Get(ctx, tenantID, ownerUID, brandID)
- if err != nil {
- return err
- }
- }
-
- updateProgress("儲存研究地圖…", 90)
-
- graph.BraveSources = braveSources
- now := clock.NowUnixNano()
- saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: brandID,
- TopicID: scope.TopicID,
- Seed: graph.Seed,
- Nodes: graph.Nodes,
- Edges: graph.Edges,
- BraveSources: graph.BraveSources,
- ExpandStrategy: expandStrategy.String(),
- PainTagCount: graph.PainTagCount,
- GeneratedAt: now,
- })
- if err != nil {
- return err
- }
-
- if err := syncResearchMapSources(ctx, deps, tenantID, ownerUID, scope, expandStrategy.String(), braveSources); err != nil {
- return err
- }
-
- nextRoute := "/research?brand=" + brandID
- if scope.TopicID != "" {
- nextRoute = "/placement/topics/" + scope.TopicID + "/research-map"
- }
- handoff := map[string]any{
- "flow": "placement",
- "brand_id": brandID,
- "topic_id": scope.TopicID,
- "pain_tag_count": saved.PainTagCount,
- "summary": fmt.Sprintf("圖譜 %d 節點,痛點候選 %d", len(saved.Nodes), saved.PainTagCount),
- "next_route": nextRoute,
- "needs_supplemental_expand": saved.PainTagCount < libkg.MinPainTagCandidates() || len(saved.Nodes) < libkg.MinBreadthGraphNodes(),
- "search_source_mode": string(memberCtx.SearchSourceMode),
- "dev_mode": memberCtx.DevMode,
- }
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "graph_id": saved.ID,
- "seed": saved.Seed,
- "pain_tag_count": saved.PainTagCount,
- "node_count": len(saved.Nodes),
- "search_source_mode": string(memberCtx.SearchSourceMode),
- "handoff": handoff,
- },
- })
- return err
-}
-
-func runWebKnowledgeExpand(
- ctx context.Context,
- client websearch.Client,
- member placement.MemberContext,
- queries []string,
- strategy libkg.ExpandStrategy,
- onProgress func(i, total int),
- heartbeat func() error,
-) ([]libkg.BraveSource, error) {
- if client == nil || !client.Enabled() {
- return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{
- WebSearchProvider: member.WebSearchProvider,
- BraveAPIKey: member.BraveAPIKey,
- ExaAPIKey: member.ExaAPIKey,
- }))
- }
- if len(queries) == 0 {
- if strategy == libkg.ExpandStrategyHybrid {
- return nil, nil
- }
- return nil, app.For(code.Setting).InputMissingRequired("沒有可執行的網路搜尋查詢")
- }
- cfg := libkg.DefaultBraveCollectConfig()
- out := libkg.CollectWebSources(ctx, client, libkg.BraveSearchLocale{
- Country: member.BraveCountry,
- SearchLang: member.BraveSearchLang,
- UserLocation: member.ExaUserLocation,
- }, queries, cfg, onProgress, heartbeat)
- if len(out) == 0 {
- return nil, app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料,請稍後重試")
- }
- return out, nil
-}
-
-func kgPlanInput(
- brand *branddomain.BrandSummary,
- seed, productBrief string,
- l1Labels []string,
- supplemental bool,
- strategy libkg.ExpandStrategy,
-) libkg.PlanInput {
- return libkg.PlanInput{
- Seed: seed,
- TargetAudience: brand.TargetAudience,
- ProductBrief: productBrief,
- Pillars: brand.ResearchMap.Pillars,
- Questions: brand.ResearchMap.Questions,
- PatrolKeywords: brand.ResearchMap.PatrolKeywords,
- L1Labels: l1Labels,
- Supplemental: supplemental,
- Strategy: strategy,
- }
-}
-
-func kgSynthInput(
- brand *branddomain.BrandSummary,
- seed, productBrief string,
- sources []libkg.BraveSource,
-) libkg.SynthInput {
- topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief)
- return libkg.SynthInput{
- BrandDisplayName: topicCtx.BrandDisplayName,
- TopicName: topicCtx.TopicName,
- ProductLabel: topicCtx.ProductDisplayName(),
- Goals: topicCtx.Goals,
- Seed: seed,
- ProductBrief: productBrief,
- TargetAudience: brand.TargetAudience,
- Persona: brand.Brief,
- ResearchPillars: brand.ResearchMap.Pillars,
- ResearchQuestions: brand.ResearchMap.Questions,
- Sources: sources,
- }
-}
-
-func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSources []libkg.BraveSource) libkg.Graph {
- if existing == nil {
- return incoming
- }
- merged := libkg.Graph{
- Seed: existing.Seed,
- Nodes: append([]libkg.Node{}, existing.Nodes...),
- Edges: append([]libkg.Edge{}, existing.Edges...),
- BraveSources: append([]libkg.BraveSource{}, existing.BraveSources...),
- }
- seenLabel := map[string]struct{}{}
- for _, node := range merged.Nodes {
- seenLabel[strings.ToLower(strings.TrimSpace(node.Label))] = struct{}{}
- }
- for _, node := range incoming.Nodes {
- key := strings.ToLower(strings.TrimSpace(node.Label))
- if _, ok := seenLabel[key]; ok {
- continue
- }
- seenLabel[key] = struct{}{}
- merged.Nodes = append(merged.Nodes, node)
- }
- edgeSeen := map[string]struct{}{}
- for _, edge := range merged.Edges {
- edgeSeen[edge.From+"->"+edge.To] = struct{}{}
- }
- for _, edge := range incoming.Edges {
- key := edge.From + "->" + edge.To
- if _, ok := edgeSeen[key]; ok {
- continue
- }
- edgeSeen[key] = struct{}{}
- merged.Edges = append(merged.Edges, edge)
- }
- merged.BraveSources = append(merged.BraveSources, extraSources...)
- return merged
-}
-
-func int64Field(payload map[string]any, key string) int64 {
- if payload == nil {
- return 0
- }
- switch v := payload[key].(type) {
- case float64:
- return int64(v)
- case int64:
- return v
- case int:
- return int64(v)
- case json.Number:
- n, _ := v.Int64()
- return n
- default:
- return 0
- }
-}
-
-func stringField(payload map[string]any, key string) string {
- if payload == nil {
- return ""
- }
- raw, ok := payload[key]
- if !ok || raw == nil {
- return ""
- }
- switch v := raw.(type) {
- case string:
- return strings.TrimSpace(v)
- default:
- return strings.TrimSpace(fmt.Sprint(v))
- }
-}
-
-func boolField(payload map[string]any, key string) bool {
- if payload == nil {
- return false
- }
- raw, ok := payload[key]
- if !ok || raw == nil {
- return false
- }
- switch v := raw.(type) {
- case bool:
- return v
- case string:
- return strings.EqualFold(strings.TrimSpace(v), "true")
- default:
- return false
- }
-}
-
-func max(a, b int) int {
- if a > b {
- return a
- }
- return b
-}
-
-func syncAutoPatrolKeywords(
- ctx context.Context,
- deps ExpandGraphDeps,
- tenantID, ownerUID string,
- scope *placementScope,
- nodes []libkg.Node,
- productBrief string,
-) error {
- if scope == nil || scope.Brand == nil {
- return nil
- }
- fresh := scope.Brand
- if scope.TopicID != "" {
- topic, err := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID)
- if err != nil {
- return err
- }
- fresh = placementTopicAsBrand(scope, topic)
- } else {
- loaded, err := deps.Brand.Get(ctx, tenantID, ownerUID, scope.CatalogBrand)
- if err != nil {
- return err
- }
- fresh = loaded
- }
- patrolInput := libkg.PatrolTagInputFromBrand(fresh, productBrief)
- tags := libkg.CollectPatrolTagsFromGraph(patrolInput, nodes)
- if len(tags) == 0 {
- return nil
- }
- entityMap := brandentity.ResearchMap{
- AudienceSummary: fresh.ResearchMap.AudienceSummary,
- ContentGoal: fresh.ResearchMap.ContentGoal,
- Questions: fresh.ResearchMap.Questions,
- Pillars: fresh.ResearchMap.Pillars,
- Exclusions: fresh.ResearchMap.Exclusions,
- ResearchItems: fresh.ResearchMap.ResearchItems,
- ExpandStrategy: fresh.ResearchMap.ExpandStrategy,
- PatrolKeywords: tags,
- }
- return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil)
-}
-
-func syncResearchMapSources(
- ctx context.Context,
- deps ExpandGraphDeps,
- tenantID, ownerUID string,
- scope *placementScope,
- expandStrategy string,
- sources []libkg.BraveSource,
-) error {
- if expandStrategy == "" || scope == nil || scope.Brand == nil {
- return nil
- }
- items := make([]brandentity.ResearchItem, 0, len(sources))
- for _, src := range sources {
- if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" {
- continue
- }
- items = append(items, brandentity.ResearchItem{
- Title: src.Title,
- URL: src.URL,
- Snippet: src.Snippet,
- Query: src.Query,
- })
- }
- fresh, err := reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
- if err != nil {
- return err
- }
- if fresh == nil {
- return nil
- }
- entityMap := brandentity.ResearchMap{
- AudienceSummary: fresh.ResearchMap.AudienceSummary,
- ContentGoal: fresh.ResearchMap.ContentGoal,
- Questions: fresh.ResearchMap.Questions,
- Pillars: fresh.ResearchMap.Pillars,
- Exclusions: fresh.ResearchMap.Exclusions,
- ResearchItems: items,
- ExpandStrategy: expandStrategy,
- PatrolKeywords: fresh.ResearchMap.PatrolKeywords,
- }
- return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil)
-}
-
-func ensureResearchMap(
- ctx context.Context,
- step StepContext,
- deps ExpandGraphDeps,
- brand *branddomain.BrandSummary,
- productBrief string,
- providerID enum.ProviderID,
- credential *threadsaccountdomain.WorkerAiCredential,
- updateProgress func(string, int),
-) error {
- tenantID := stringField(step.Run.Payload, "tenant_id")
- ownerUID := stringField(step.Run.Payload, "owner_uid")
- if tenantID == "" || ownerUID == "" || brand == nil {
- return fmt.Errorf("research map: missing actor or brand")
- }
-
- topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief)
- mapInput := topicCtx.ToResearchMapInput()
-
- updateProgress("分析主題脈絡…", 6)
- analysisResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: placement.BuildResearchMapAnalysisSystemPrompt(),
- Messages: []domai.Message{
- {Role: "user", Content: placement.BuildResearchMapAnalysisUserPrompt(mapInput)},
- },
- }))
- if err != nil {
- return err
- }
-
- finalPrompt := placement.BuildResearchMapFinalizeUserPrompt(mapInput, analysisResult.Text)
- updateProgress("產出研究地圖…", 7)
- result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: placement.BuildResearchMapSystemPrompt(),
- Messages: []domai.Message{
- {Role: "user", Content: finalPrompt},
- },
- }))
- if err != nil {
- return err
- }
-
- parsed, err := placement.ParseResearchMapOutput(result.Text)
- if err != nil {
- // Model sometimes replies with reasoning/prose and no JSON object.
- // Retry once, explicitly demanding a raw JSON object.
- jsonOnlyResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: placement.BuildResearchMapSystemPrompt(),
- Messages: []domai.Message{
- {Role: "user", Content: finalPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: placement.ResearchMapJSONOnlyRetryPrompt()},
- },
- }))
- if retryErr != nil {
- return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error())
- }
- parsed, err = placement.ParseResearchMapOutput(jsonOnlyResult.Text)
- if err != nil {
- return app.For(code.AI).SvcThirdParty("研究地圖產生失敗,請重試:" + err.Error())
- }
- result = jsonOnlyResult
- }
- if placement.ResearchMapTooThin(parsed) {
- if retryResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: placement.BuildResearchMapSystemPrompt(),
- Messages: []domai.Message{
- {Role: "user", Content: finalPrompt},
- {Role: "assistant", Content: result.Text},
- {Role: "user", Content: placement.ResearchMapRetryUserPrompt()},
- },
- })); retryErr == nil {
- if retryParsed, parseErr := placement.ParseResearchMapOutput(retryResult.Text); parseErr == nil && !placement.ResearchMapTooThin(retryParsed) {
- parsed = retryParsed
- }
- }
- }
-
- patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
- entityMap := brandentity.ResearchMap{
- AudienceSummary: parsed.AudienceSummary,
- ContentGoal: parsed.ContentGoal,
- Questions: libkg.RankStringsByIntent(parsed.Questions, patrolInput),
- Pillars: libkg.RankStringsByIntent(parsed.Pillars, patrolInput),
- Exclusions: parsed.Exclusions,
- PatrolKeywords: libkg.FilterPatrolKeywordsByIntent(
- libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords),
- patrolInput,
- 18,
- ),
- }
- targetAudience := strings.TrimSpace(brand.TargetAudience)
- if targetAudience == "" {
- targetAudience = parsed.AudienceSummary
- }
- topicID := topicIDFromPayload(step.Run.Payload)
- scope := &placementScope{
- TopicID: topicID,
- CatalogBrand: brand.ID,
- Brand: brand,
- }
- if err := updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, &targetAudience); err != nil {
- return err
- }
- updateProgress("研究地圖已就緒", 8)
- return nil
-}
-
-func updatePlacementResearchMap(
- ctx context.Context,
- deps ExpandGraphDeps,
- tenantID, ownerUID string,
- scope *placementScope,
- entityMap brandentity.ResearchMap,
- targetAudience *string,
-) error {
- if scope == nil {
- return nil
- }
- if scope.TopicID != "" {
- patch := topicdomain.TopicPatch{ResearchMap: &entityMap}
- _, err := deps.PlacementTopic.Update(ctx, topicdomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- TopicID: scope.TopicID,
- Patch: patch,
- })
- return err
- }
- patch := branddomain.BrandPatch{ResearchMap: &entityMap}
- if targetAudience != nil && strings.TrimSpace(*targetAudience) != "" {
- patch.TargetAudience = targetAudience
- }
- _, err := deps.Brand.Update(ctx, branddomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: scope.CatalogBrand,
- Patch: patch,
- })
- return err
-}
diff --git a/old/backend/internal/worker/job/generate_formula_draft.go b/old/backend/internal/worker/job/generate_formula_draft.go
deleted file mode 100644
index 742b2de..0000000
--- a/old/backend/internal/worker/job/generate_formula_draft.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
-
- libpersonacopy "haixun-backend/internal/library/personacopy"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateFormulaDraftDeps struct {
- Jobs jobdom.UseCase
- Persona personadomain.UseCase
- ContentFormula contentformuladomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterGenerateFormulaDraftHandler(runner *Runner, deps GenerateFormulaDraftDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("formula_draft_generate", func(ctx context.Context, step StepContext) error {
- return runGenerateFormulaDraft(ctx, step, deps)
- })
-}
-
-func runGenerateFormulaDraft(ctx context.Context, step StepContext, deps GenerateFormulaDraftDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- accountID := stringField(payload, "account_id")
- formulaID := stringField(payload, "formula_id")
- topic := stringField(payload, "topic")
- if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || formulaID == "" || topic == "" {
- return fmt.Errorf("generate-formula-draft payload missing required fields")
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "formula_draft_generate",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- count, err := libpersonacopy.RunFormulaDraft(ctx, libpersonacopy.FormulaDraftDeps{
- Persona: deps.Persona,
- ContentFormula: deps.ContentFormula,
- CopyDraft: deps.CopyDraft,
- ThreadsAccount: deps.ThreadsAccount,
- Placement: deps.Placement,
- AI: deps.AI,
- }, libpersonacopy.FormulaDraftInput{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- AccountID: accountID,
- FormulaID: formulaID,
- Topic: topic,
- Brief: stringField(payload, "brief"),
- UseWebSearch: boolField(payload, "use_web_search"),
- DraftCount: intField(payload, "draft_count"),
- }, updateProgress)
- if err != nil {
- return err
- }
- _ = count
- return nil
-}
diff --git a/old/backend/internal/worker/job/generate_outreach_draft.go b/old/backend/internal/worker/job/generate_outreach_draft.go
deleted file mode 100644
index 64cee8c..0000000
--- a/old/backend/internal/worker/job/generate_outreach_draft.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
-
- liboutreach "haixun-backend/internal/library/outreach"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- kgdomain "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- outreachdraftdomain "haixun-backend/internal/model/outreach_draft/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- scanpostdomain "haixun-backend/internal/model/scan_post/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateOutreachDraftDeps struct {
- Jobs jobdom.UseCase
- Brand branddomain.UseCase
- Persona personadomain.UseCase
- ScanPost scanpostdomain.UseCase
- KnowledgeGraph kgdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- AI aiusecase.UseCase
- OutreachDraft outreachdraftdomain.UseCase
-}
-
-func RegisterGenerateOutreachDraftHandler(runner *Runner, deps GenerateOutreachDraftDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("outreach_draft_generate", func(ctx context.Context, step StepContext) error {
- return runGenerateOutreachDraft(ctx, step, deps)
- })
-}
-
-func runGenerateOutreachDraft(ctx context.Context, step StepContext, deps GenerateOutreachDraftDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- brandID := stringField(payload, "brand_id")
- topicID := stringField(payload, "topic_id")
- scanPostID := stringField(payload, "scan_post_id")
- voicePersonaID := stringField(payload, "voice_persona_id")
- if tenantID == "" || ownerUID == "" || brandID == "" || scanPostID == "" || voicePersonaID == "" {
- return fmt.Errorf("generate-outreach-draft payload missing tenant_id, owner_uid, brand_id, scan_post_id, or voice_persona_id")
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "outreach_draft_generate",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- updateProgress("讀取貼文與產品脈絡…", 15)
- updateProgress("呼叫 AI 生成獲客草稿…", 45)
-
- count := intField(payload, "count")
- if count <= 0 {
- count = 3
- }
-
- regenerate := false
- if v, ok := payload["regenerate"].(bool); ok {
- regenerate = v
- }
-
- _, err := liboutreach.GenerateDraft(ctx, liboutreach.GenerateDraftDeps{
- Brand: deps.Brand,
- Persona: deps.Persona,
- ScanPost: deps.ScanPost,
- KnowledgeGraph: deps.KnowledgeGraph,
- ThreadsAccount: deps.ThreadsAccount,
- AI: deps.AI,
- OutreachDraft: deps.OutreachDraft,
- }, liboutreach.GenerateDraftRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: brandID,
- TopicID: topicID,
- ScanPostID: scanPostID,
- Count: count,
- VoicePersonaID: voicePersonaID,
- ProductID: stringField(payload, "product_id"),
- Regenerate: regenerate,
- })
- if err != nil {
- return err
- }
- updateProgress("草稿已寫入", 100)
- return nil
-}
diff --git a/old/backend/internal/worker/job/generate_rewrite_draft.go b/old/backend/internal/worker/job/generate_rewrite_draft.go
deleted file mode 100644
index 157cd67..0000000
--- a/old/backend/internal/worker/job/generate_rewrite_draft.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
-
- libpersonacopy "haixun-backend/internal/library/personacopy"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- contentformuladomain "haixun-backend/internal/model/content_formula/domain/usecase"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateRewriteDraftDeps struct {
- Jobs jobdom.UseCase
- Persona personadomain.UseCase
- ContentFormula contentformuladomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterGenerateRewriteDraftHandler(runner *Runner, deps GenerateRewriteDraftDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("rewrite_draft_generate", func(ctx context.Context, step StepContext) error {
- return runGenerateRewriteDraft(ctx, step, deps)
- })
-}
-
-func runGenerateRewriteDraft(ctx context.Context, step StepContext, deps GenerateRewriteDraftDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- accountID := stringField(payload, "account_id")
- referenceText := stringField(payload, "reference_text")
- topic := stringField(payload, "topic")
- if tenantID == "" || ownerUID == "" || personaID == "" || accountID == "" || referenceText == "" || topic == "" {
- return fmt.Errorf("generate-rewrite-draft payload missing required fields")
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "rewrite_draft_generate",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- count, err := libpersonacopy.RunRewriteDraft(ctx, libpersonacopy.RewriteDraftDeps{
- Persona: deps.Persona,
- ContentFormula: deps.ContentFormula,
- CopyDraft: deps.CopyDraft,
- ThreadsAccount: deps.ThreadsAccount,
- Placement: deps.Placement,
- AI: deps.AI,
- }, libpersonacopy.RewriteDraftInput{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- AccountID: accountID,
- ReferenceText: referenceText,
- Topic: topic,
- Brief: stringField(payload, "brief"),
- SaveLabel: stringField(payload, "save_label"),
- UseWebSearch: boolField(payload, "use_web_search"),
- DraftCount: intField(payload, "draft_count"),
- }, updateProgress)
- if err != nil {
- return err
- }
- _ = count
- return nil
-}
diff --git a/old/backend/internal/worker/job/generate_topic_matrix.go b/old/backend/internal/worker/job/generate_topic_matrix.go
deleted file mode 100644
index 700b416..0000000
--- a/old/backend/internal/worker/job/generate_topic_matrix.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
-
- libpersonacopy "haixun-backend/internal/library/personacopy"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- contentopsdomain "haixun-backend/internal/model/content_ops/domain/usecase"
- copydraftdomain "haixun-backend/internal/model/copy_draft/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type GenerateTopicMatrixDeps struct {
- Jobs jobdom.UseCase
- Persona personadomain.UseCase
- CopyDraft copydraftdomain.UseCase
- ContentOps contentopsdomain.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterGenerateTopicMatrixHandler(runner *Runner, deps GenerateTopicMatrixDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("topic_matrix_generate", func(ctx context.Context, step StepContext) error {
- return runGenerateTopicMatrix(ctx, step, deps)
- })
-}
-
-func runGenerateTopicMatrix(ctx context.Context, step StepContext, deps GenerateTopicMatrixDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := stringField(payload, "persona_id")
- topic := stringField(payload, "topic")
- if tenantID == "" || ownerUID == "" || personaID == "" || topic == "" {
- return fmt.Errorf("generate-topic-matrix payload missing tenant_id, owner_uid, persona_id, or topic")
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "topic_matrix_generate",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- created, err := libpersonacopy.RunTopicMatrix(ctx, libpersonacopy.TopicMatrixDeps{
- Persona: deps.Persona,
- CopyDraft: deps.CopyDraft,
- ContentOps: deps.ContentOps,
- ThreadsAccount: deps.ThreadsAccount,
- Placement: deps.Placement,
- AI: deps.AI,
- }, libpersonacopy.TopicMatrixInput{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- ContentPlanID: stringField(payload, "content_plan_id"),
- Topic: topic,
- Brief: stringField(payload, "brief"),
- UseWebSearch: boolField(payload, "use_web_search"),
- DraftCount: intField(payload, "draft_count"),
- }, updateProgress)
- if err != nil {
- return err
- }
- _ = created
- return nil
-}
diff --git a/old/backend/internal/worker/job/payload_actor.go b/old/backend/internal/worker/job/payload_actor.go
deleted file mode 100644
index 2a943df..0000000
--- a/old/backend/internal/worker/job/payload_actor.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package job
-
-import (
- "strings"
-
- "haixun-backend/internal/model/job/domain/entity"
-)
-
-func runActorFromPayload(payload map[string]any, run *entity.Run) (tenantID, ownerUID string) {
- tenantID = stringField(payload, "tenant_id")
- ownerUID = stringField(payload, "owner_uid")
- if run == nil {
- return tenantID, ownerUID
- }
- if tenantID == "" {
- tenantID = strings.TrimSpace(run.TenantID)
- }
- if ownerUID == "" {
- ownerUID = strings.TrimSpace(run.OwnerUID)
- }
- return tenantID, ownerUID
-}
diff --git a/old/backend/internal/worker/job/payload_helpers.go b/old/backend/internal/worker/job/payload_helpers.go
deleted file mode 100644
index 7524168..0000000
--- a/old/backend/internal/worker/job/payload_helpers.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package job
-
-import (
- "fmt"
- "strconv"
- "strings"
-
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
-)
-
-func intField(payload map[string]any, key string) int {
- if payload == nil {
- return 0
- }
- switch v := payload[key].(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- case string:
- n, _ := strconv.Atoi(strings.TrimSpace(v))
- return n
- default:
- return 0
- }
-}
-
-func copyMissionIDFromPayload(payload map[string]any) string {
- if id := stringField(payload, "copy_mission_id"); id != "" {
- return id
- }
- return stringField(payload, "mission_id")
-}
-
-func similarAccountsFromSummary(items []missiondomain.SimilarAccountSummary) []missionentity.SimilarAccount {
- out := make([]missionentity.SimilarAccount, 0, len(items))
- for _, item := range items {
- username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@"))
- if username == "" {
- continue
- }
- out = append(out, missionentity.SimilarAccount{
- Username: username,
- Reason: item.Reason,
- Source: item.Source,
- MatchedSource: append([]string(nil), item.MatchedSource...),
- Confidence: item.Confidence,
- Status: item.Status,
- TopicRelevance: item.TopicRelevance,
- LastSeenAt: item.LastSeenAt,
- ProfileURL: item.ProfileURL,
- AuthorVerified: item.AuthorVerified,
- FollowerCount: item.FollowerCount,
- EngagementScore: item.EngagementScore,
- LikeCount: item.LikeCount,
- ReplyCount: item.ReplyCount,
- PostCount: item.PostCount,
- })
- }
- return out
-}
-
-func audienceSamplesFromSummary(items []missiondomain.AudienceSampleSummary) []missionentity.AudienceSample {
- out := make([]missionentity.AudienceSample, 0, len(items))
- for _, item := range items {
- username := strings.TrimSpace(strings.TrimPrefix(item.Username, "@"))
- if username == "" {
- continue
- }
- out = append(out, missionentity.AudienceSample{
- Username: username,
- SamplePostID: item.SamplePostID,
- SampleText: item.SampleText,
- ReplyLikeCount: item.ReplyLikeCount,
- Appearances: item.Appearances,
- FirstSeenAt: item.FirstSeenAt,
- LastSeenAt: item.LastSeenAt,
- Status: item.Status,
- })
- }
- return out
-}
-
-func debugPayloadValue(value any) string {
- return fmt.Sprintf("%v", value)
-}
diff --git a/old/backend/internal/worker/job/placement_scope.go b/old/backend/internal/worker/job/placement_scope.go
deleted file mode 100644
index b54e37c..0000000
--- a/old/backend/internal/worker/job/placement_scope.go
+++ /dev/null
@@ -1,100 +0,0 @@
-package job
-
-import (
- "context"
- "strings"
-
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
-)
-
-type placementScope struct {
- TopicID string
- CatalogBrand string
- Topic *topicdomain.TopicSummary
- Brand *branddomain.BrandSummary
-}
-
-func topicIDFromPayload(payload map[string]any) string {
- return strings.TrimSpace(stringField(payload, "topic_id"))
-}
-
-func resolvePlacementScope(
- ctx context.Context,
- brandUC branddomain.UseCase,
- topicUC topicdomain.UseCase,
- tenantID, ownerUID string,
- payload map[string]any,
-) (*placementScope, error) {
- topicID := topicIDFromPayload(payload)
- catalogBrandID := strings.TrimSpace(stringField(payload, "brand_id"))
- if catalogBrandID == "" {
- catalogBrandID = brandIDFromPayload(payload)
- }
- if topicID != "" {
- topic, err := topicUC.Get(ctx, tenantID, ownerUID, topicID)
- if err != nil {
- return nil, err
- }
- catalogBrandID = topic.BrandID
- catalog, err := brandUC.Get(ctx, tenantID, ownerUID, catalogBrandID)
- if err != nil {
- return nil, err
- }
- merged := *catalog
- merged.TopicName = topic.TopicName
- merged.SeedQuery = topic.SeedQuery
- merged.Brief = topic.Brief
- merged.ProductID = topic.ProductID
- merged.ResearchMap = topic.ResearchMap
- return &placementScope{
- TopicID: topicID,
- CatalogBrand: catalogBrandID,
- Topic: topic,
- Brand: &merged,
- }, nil
- }
- brand, err := brandUC.Get(ctx, tenantID, ownerUID, catalogBrandID)
- if err != nil {
- return nil, err
- }
- return &placementScope{
- CatalogBrand: catalogBrandID,
- Brand: brand,
- }, nil
-}
-
-func placementTopicAsBrand(scope *placementScope, topic *topicdomain.TopicSummary) *branddomain.BrandSummary {
- if scope == nil || scope.Brand == nil || topic == nil {
- return scope.Brand
- }
- out := *scope.Brand
- out.TopicName = topic.TopicName
- out.SeedQuery = topic.SeedQuery
- out.Brief = topic.Brief
- out.ProductID = topic.ProductID
- out.ResearchMap = topic.ResearchMap
- return &out
-}
-
-func reloadScopeBrand(
- ctx context.Context,
- deps ExpandGraphDeps,
- tenantID, ownerUID string,
- scope *placementScope,
-) (*branddomain.BrandSummary, error) {
- if scope == nil || scope.Brand == nil {
- return nil, nil
- }
- if scope.TopicID != "" && deps.PlacementTopic != nil {
- topic, err := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID)
- if err != nil {
- return nil, err
- }
- return placementTopicAsBrand(scope, topic), nil
- }
- if deps.Brand != nil && strings.TrimSpace(scope.CatalogBrand) != "" {
- return deps.Brand.Get(ctx, tenantID, ownerUID, scope.CatalogBrand)
- }
- return scope.Brand, nil
-}
diff --git a/old/backend/internal/worker/job/publish_analytics.go b/old/backend/internal/worker/job/publish_analytics.go
deleted file mode 100644
index 6067352..0000000
--- a/old/backend/internal/worker/job/publish_analytics.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- libthreads "haixun-backend/internal/library/threadsapi"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- publishanalyticsdomain "haixun-backend/internal/model/publish_analytics/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type PublishAnalyticsDeps struct {
- Jobs jobdom.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- PublishAnalytics publishanalyticsdomain.UseCase
-}
-
-func RegisterPublishAnalyticsHandler(runner *Runner, deps PublishAnalyticsDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("check", func(ctx context.Context, step StepContext) error {
- return runPublishAnalyticsCheck(ctx, step, deps)
- })
-}
-
-func runPublishAnalyticsCheck(ctx context.Context, step StepContext, deps PublishAnalyticsDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- mediaID := strings.TrimSpace(stringField(payload, "media_id"))
- checkpoint := strings.TrimSpace(stringField(payload, "checkpoint"))
- permalink := strings.TrimSpace(stringField(payload, "permalink"))
- publishedAt := int64Field(payload, "published_at")
- if mediaID == "" || checkpoint == "" {
- return fmt.Errorf("publish-analytics payload missing media_id or checkpoint")
- }
-
- accountID := strings.TrimSpace(stringField(payload, "account_id"))
- if accountID == "" {
- accountID = strings.TrimSpace(step.Run.ScopeID)
- }
- token, err := deps.ThreadsAccount.ResolveAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return fmt.Errorf("resolve api token: %w", err)
- }
- if token == "" {
- return fmt.Errorf("no access token found")
- }
-
- stats, err := libthreads.GetMediaStats(ctx, token, mediaID)
- if err != nil {
- return fmt.Errorf("get media stats: %w", err)
- }
-
- saved, err := deps.PublishAnalytics.RecordCheckpoint(ctx, tenantID, ownerUID, publishanalyticsdomain.PublishAnalyticsSummary{
- MediaID: mediaID,
- Permalink: permalink,
- PublishedAt: publishedAt,
- Checkpoint: checkpoint,
- LikeCount: stats.LikeCount,
- ReplyCount: stats.ReplyCount,
- RepostCount: stats.RepostCount,
- QuoteCount: stats.QuoteCount,
- ViewCount: stats.Views,
- ShareCount: stats.Shares,
- })
- if err != nil {
- return fmt.Errorf("record checkpoint: %w", err)
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "media_id": mediaID,
- "checkpoint": checkpoint,
- "like_count": saved.LikeCount,
- "reply_count": saved.ReplyCount,
- "repost_count": saved.RepostCount,
- "quote_count": saved.QuoteCount,
- "view_count": saved.ViewCount,
- "share_count": saved.ShareCount,
- "analytics_id": saved.ID,
- },
- })
- return err
-}
diff --git a/old/backend/internal/worker/job/publish_queue_sweeper.go b/old/backend/internal/worker/job/publish_queue_sweeper.go
deleted file mode 100644
index 746ccbb..0000000
--- a/old/backend/internal/worker/job/publish_queue_sweeper.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package job
-
-import (
- "context"
- "log"
- "time"
-
- publishqueuedomain "haixun-backend/internal/model/publish_queue/domain/usecase"
-)
-
-type PublishQueueSweeper struct {
- queue publishqueuedomain.UseCase
- batchSize int
-}
-
-func NewPublishQueueSweeper(queue publishqueuedomain.UseCase, batchSize int) *PublishQueueSweeper {
- if batchSize <= 0 {
- batchSize = 20
- }
- return &PublishQueueSweeper{queue: queue, batchSize: batchSize}
-}
-
-func (s *PublishQueueSweeper) Start(ctx context.Context, interval time.Duration) {
- if s == nil || s.queue == nil {
- return
- }
- if interval <= 0 {
- interval = time.Minute
- }
- log.Printf("publish queue sweeper started: interval=%s batch=%d", interval, s.batchSize)
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- log.Printf("publish queue sweeper stopped")
- return
- case <-ticker.C:
- missed, err := s.queue.MarkMissed(ctx, int64(10*time.Minute), s.batchSize)
- if err != nil {
- log.Printf("publish queue missed sweep error: %v", err)
- } else if missed > 0 {
- log.Printf("publish queue marked %d missed item(s)", missed)
- }
- dispatched, err := s.queue.DispatchDue(ctx, s.batchSize)
- if err != nil {
- log.Printf("publish queue sweeper tick error: %v", err)
- continue
- }
- if dispatched > 0 {
- log.Printf("publish queue sweeper dispatched %d item(s)", dispatched)
- }
- }
- }
-}
diff --git a/old/backend/internal/worker/job/reaper.go b/old/backend/internal/worker/job/reaper.go
deleted file mode 100644
index fd3dedf..0000000
--- a/old/backend/internal/worker/job/reaper.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package job
-
-import (
- "context"
- "log"
- "time"
-
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-type Reaper struct {
- jobs domusecase.UseCase
-}
-
-func NewReaper(jobs domusecase.UseCase) *Reaper {
- return &Reaper{jobs: jobs}
-}
-
-func (r *Reaper) Start(ctx context.Context, interval time.Duration) {
- if interval <= 0 {
- interval = 30 * time.Second
- }
- log.Printf("job reaper started: interval=%s", interval)
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- log.Printf("job reaper stopped")
- return
- case <-ticker.C:
- result, err := r.jobs.RunMaintenance(ctx)
- if err != nil {
- log.Printf("job reaper error: %v", err)
- continue
- }
- if result.EnqueuedPending > 0 || result.ReapedCancelGrace > 0 || result.ReapedExpiredLocks > 0 {
- log.Printf("job reaper: enqueued=%d cancel_grace=%d expired=%d",
- result.EnqueuedPending, result.ReapedCancelGrace, result.ReapedExpiredLocks)
- }
- }
- }
-}
diff --git a/old/backend/internal/worker/job/refresh_threads_token.go b/old/backend/internal/worker/job/refresh_threads_token.go
deleted file mode 100644
index 500f74f..0000000
--- a/old/backend/internal/worker/job/refresh_threads_token.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type RefreshThreadsTokenDeps struct {
- Jobs jobdom.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
-}
-
-func RegisterRefreshThreadsTokenHandler(runner *Runner, deps RefreshThreadsTokenDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("refresh", func(ctx context.Context, step StepContext) error {
- return runRefreshThreadsToken(ctx, step, deps)
- })
-}
-
-func runRefreshThreadsToken(ctx context.Context, step StepContext, deps RefreshThreadsTokenDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- accountID := strings.TrimSpace(stringField(payload, "account_id"))
- if tenantID == "" || ownerUID == "" || accountID == "" {
- return fmt.Errorf("refresh-threads-token payload missing tenant_id, owner_uid, or account_id")
- }
-
- saved, err := deps.ThreadsAccount.RefreshAccountAPIAccessToken(ctx, tenantID, ownerUID, accountID)
- if err != nil {
- return fmt.Errorf("refresh account token: %w", err)
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "account_id": accountID,
- "expires_at": saved.APITokenExpiresAt,
- },
- })
- return err
-}
diff --git a/old/backend/internal/worker/job/runner.go b/old/backend/internal/worker/job/runner.go
deleted file mode 100644
index 513b209..0000000
--- a/old/backend/internal/worker/job/runner.go
+++ /dev/null
@@ -1,235 +0,0 @@
-package job
-
-import (
- "context"
- "errors"
- "fmt"
- "log"
- "time"
-
- "haixun-backend/internal/library/clock"
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/model/job/resume"
-)
-
-type Runner struct {
- workerID string
- workerType string
- jobs domusecase.UseCase
- handlers map[string]StepHandler
-}
-
-type StepContext struct {
- JobID string
- WorkerID string
- Run *entity.Run
- Template *entity.Template
- Step entity.TemplateStep
- Heartbeat func(ctx context.Context) error
-}
-
-type StepHandler func(ctx context.Context, step StepContext) error
-
-func NewRunner(workerID, workerType string, jobs domusecase.UseCase) *Runner {
- return &Runner{
- workerID: workerID,
- workerType: workerType,
- jobs: jobs,
- handlers: map[string]StepHandler{},
- }
-}
-
-func (r *Runner) RegisterStepHandler(stepID string, handler StepHandler) {
- if stepID == "" || handler == nil {
- return
- }
- r.handlers[stepID] = handler
-}
-
-func (r *Runner) Start(ctx context.Context) {
- log.Printf("job worker started: id=%s type=%s", r.workerID, r.workerType)
- for {
- select {
- case <-ctx.Done():
- log.Printf("job worker stopped: id=%s", r.workerID)
- return
- default:
- run, err := r.jobs.ClaimNext(ctx, domusecase.ClaimNextRequest{
- WorkerType: r.workerType,
- WorkerID: r.workerID,
- })
- if err != nil {
- log.Printf("job worker claim error: %v", err)
- time.Sleep(time.Second)
- continue
- }
- if run == nil {
- continue
- }
- r.execute(ctx, run)
- }
- }
-}
-
-func (r *Runner) execute(ctx context.Context, run *entity.Run) {
- jobID := run.ID.Hex()
- template, err := r.jobs.GetTemplate(ctx, run.TemplateType)
- if err != nil {
- _, _ = r.jobs.FailRun(ctx, domusecase.FailRunRequest{JobID: jobID, WorkerID: r.workerID, Error: err.Error()})
- return
- }
-
- steps := make([]entity.StepProgress, len(run.Progress.Steps))
- copy(steps, run.Progress.Steps)
- totalSteps := len(template.Steps)
- if totalSteps == 0 {
- totalSteps = 1
- }
- completedBefore := resume.CountSucceededSteps(steps)
-
- for _, step := range template.Steps {
- existing := resume.StepProgressByID(steps, step.ID)
- if existing != nil && resume.ShouldSkipStep(existing.Status) {
- continue
- }
-
- if cancelled, _ := r.jobs.IsCancelRequested(ctx, jobID); cancelled {
- _, _ = r.jobs.AcknowledgeCancel(ctx, domusecase.AcknowledgeCancelRequest{
- JobID: jobID,
- WorkerID: r.workerID,
- })
- return
- }
-
- now := clock.NowUnixNano()
- for i := range steps {
- if steps[i].ID == step.ID {
- steps[i].Status = enum.StepStatusRunning
- steps[i].StartedAt = &now
- steps[i].Message = "running"
- }
- }
- percentage := (completedBefore * 100) / totalSteps
- if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
- JobID: jobID,
- WorkerID: r.workerID,
- Phase: step.ID,
- Summary: "running step " + step.ID,
- Percentage: percentage,
- Steps: steps,
- }); err != nil {
- log.Printf("job worker update progress (running) failed: job=%s step=%s err=%v", jobID, step.ID, err)
- }
-
- if err := r.runStep(ctx, run, template, step); err != nil {
- if err == errJobCancelled {
- _, _ = r.jobs.AcknowledgeCancel(ctx, domusecase.AcknowledgeCancelRequest{
- JobID: jobID,
- WorkerID: r.workerID,
- })
- return
- }
- for i := range steps {
- if steps[i].ID == step.ID {
- steps[i].Status = enum.StepStatusFailed
- ended := clock.NowUnixNano()
- steps[i].EndedAt = &ended
- steps[i].Message = err.Error()
- }
- }
- _, _ = r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
- JobID: jobID, WorkerID: r.workerID, Phase: step.ID, Summary: "step failed", Steps: steps,
- })
- _, _ = r.jobs.FailRun(ctx, domusecase.FailRunRequest{
- JobID: jobID, WorkerID: r.workerID, Error: err.Error(), Phase: step.ID,
- })
- return
- }
-
- ended := clock.NowUnixNano()
- for i := range steps {
- if steps[i].ID == step.ID {
- steps[i].Status = enum.StepStatusSucceeded
- steps[i].EndedAt = &ended
- steps[i].Message = "done"
- }
- }
- completedBefore++
- percentage = (completedBefore * 100) / totalSteps
- if _, err := r.jobs.UpdateProgress(ctx, domusecase.UpdateProgressRequest{
- JobID: jobID,
- WorkerID: r.workerID,
- Phase: step.ID,
- Summary: "completed step " + step.ID,
- Percentage: percentage,
- Steps: steps,
- }); err != nil {
- log.Printf("job worker update progress (completed step) failed: job=%s step=%s err=%v", jobID, step.ID, err)
- }
- }
-
- fresh, err := r.jobs.GetRun(ctx, jobID)
- if err == nil && fresh != nil && fresh.Status.IsTerminal() {
- return
- }
- if _, err := r.jobs.CompleteRun(ctx, domusecase.CompleteRunRequest{
- JobID: jobID,
- WorkerID: r.workerID,
- Result: map[string]any{
- "template": run.TemplateType,
- "steps": len(template.Steps),
- },
- }); err != nil {
- log.Printf("job worker complete run failed: job=%s err=%v", jobID, err)
- }
-}
-
-var errJobCancelled = errors.New("job cancelled")
-
-func (r *Runner) runStep(ctx context.Context, run *entity.Run, template *entity.Template, step entity.TemplateStep) error {
- jobID := run.ID.Hex()
- if handler := r.handlers[step.ID]; handler != nil {
- return handler(ctx, StepContext{
- JobID: jobID,
- WorkerID: r.workerID,
- Run: run,
- Template: template,
- Step: step,
- Heartbeat: func(ctx context.Context) error {
- return r.jobs.RefreshRunLock(ctx, jobID, r.workerID, 600)
- },
- })
- }
- if run.TemplateType != "demo_long_task" {
- return fmt.Errorf("no handler registered for step %q (template %s)", step.ID, run.TemplateType)
- }
- ticks := 10
- sleepEach := 500 * time.Millisecond
- if step.ID == "execute" {
- ticks = 20
- }
-
- for i := 0; i < ticks; i++ {
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
-
- if err := r.jobs.RefreshRunLock(ctx, jobID, r.workerID, 300); err != nil {
- return err
- }
- cancelled, err := r.jobs.IsCancelRequested(ctx, jobID)
- if err != nil {
- return err
- }
- if cancelled {
- return errJobCancelled
- }
-
- time.Sleep(sleepEach)
- }
- return nil
-}
diff --git a/old/backend/internal/worker/job/runner_test.go b/old/backend/internal/worker/job/runner_test.go
deleted file mode 100644
index 06526c9..0000000
--- a/old/backend/internal/worker/job/runner_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package job
-
-import (
- "context"
- "testing"
-
- "haixun-backend/internal/model/job/domain/entity"
- "haixun-backend/internal/model/job/domain/enum"
- domusecase "haixun-backend/internal/model/job/domain/usecase"
- "haixun-backend/internal/model/job/resume"
-
- "go.mongodb.org/mongo-driver/bson/primitive"
-)
-
-type stubJobUseCase struct {
- domusecase.UseCase
- template *entity.Template
-}
-
-func (s *stubJobUseCase) GetTemplate(context.Context, string) (*entity.Template, error) {
- return s.template, nil
-}
-
-func TestRunner_SkipsSucceededSteps(t *testing.T) {
- steps := []entity.StepProgress{
- {ID: "prepare", Status: enum.StepStatusSucceeded},
- {ID: "execute", Status: enum.StepStatusPending},
- {ID: "finalize", Status: enum.StepStatusPending},
- }
- template := &entity.Template{
- Type: "demo",
- Steps: []entity.TemplateStep{
- {ID: "prepare", WorkerType: "go"},
- {ID: "execute", WorkerType: "go"},
- {ID: "finalize", WorkerType: "go"},
- },
- }
-
- ran := make([]string, 0, 3)
- for _, step := range template.Steps {
- existing := resume.StepProgressByID(steps, step.ID)
- if existing != nil && resume.ShouldSkipStep(existing.Status) {
- continue
- }
- ran = append(ran, step.ID)
- }
-
- if len(ran) != 2 || ran[0] != "execute" || ran[1] != "finalize" {
- t.Fatalf("ran = %v, want [execute finalize]", ran)
- }
- _ = primitive.NewObjectID()
-}
diff --git a/old/backend/internal/worker/job/scan_placement.go b/old/backend/internal/worker/job/scan_placement.go
deleted file mode 100644
index 3500db5..0000000
--- a/old/backend/internal/worker/job/scan_placement.go
+++ /dev/null
@@ -1,401 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- libkg "haixun-backend/internal/library/knowledge"
- "haixun-backend/internal/library/placement"
- "haixun-backend/internal/library/websearch"
- branddomain "haixun-backend/internal/model/brand/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- placementtopicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type ScanPlacementDeps struct {
- Jobs jobdom.UseCase
- Brand branddomain.UseCase
- PlacementTopic placementtopicdomain.UseCase
- KnowledgeGraph kgusecase.UseCase
- ScanPost scanpostusecase.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
-}
-
-func RegisterScanPlacementHandler(runner *Runner, deps ScanPlacementDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("crawl", func(ctx context.Context, step StepContext) error {
- return runScanPlacement(ctx, step, deps)
- })
-}
-
-func runScanPlacement(ctx context.Context, step StepContext, deps ScanPlacementDeps) error {
- payload := step.Run.Payload
- tenantID := stringField(payload, "tenant_id")
- ownerUID := stringField(payload, "owner_uid")
- brandID := brandIDFromPayload(payload)
- graphID := stringField(payload, "graph_id")
-
- if tenantID == "" || ownerUID == "" || brandID == "" {
- return fmt.Errorf("placement-scan payload missing tenant_id, owner_uid, or brand_id")
- }
-
- patrolMode := boolField(payload, "patrol_mode")
- testPatrol := boolField(payload, "test_patrol")
-
- brand, brandErr := deps.Brand.Get(ctx, tenantID, ownerUID, brandID)
- if brandErr != nil {
- return brandErr
- }
- if brand == nil {
- return fmt.Errorf("brand not found")
- }
-
- topicID := topicIDFromPayload(payload)
- if topicID == "" && step.Run != nil && strings.TrimSpace(step.Run.Scope) == "placement_topic" {
- topicID = strings.TrimSpace(step.Run.ScopeID)
- }
- var graphSummary *kgusecase.GraphSummary
- var graphErr error
- if topicID != "" {
- graphSummary, graphErr = deps.KnowledgeGraph.GetByTopic(ctx, tenantID, ownerUID, topicID, brandID)
- } else {
- graphSummary, graphErr = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
- }
- if graphErr != nil {
- if patrolMode && isKnowledgeGraphNotFound(graphErr) {
- graphSummary = nil
- if graphID == "" {
- if topicID != "" {
- graphID = topicID
- } else {
- graphID = brandID
- }
- }
- } else {
- return graphErr
- }
- } else if graphID == "" {
- graphID = graphSummary.ID
- }
-
- researchMap := brand.ResearchMap
- if topicID != "" && deps.PlacementTopic != nil {
- if topic, topicErr := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, topicID); topicErr == nil && topic != nil {
- researchMap = topic.ResearchMap
- brand.ProductID = topic.ProductID
- }
- }
-
- patrolKeywords := []string{}
- if patrolMode {
- productBrief := strings.TrimSpace(brand.ProductBrief)
- if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
- productBrief = formatted
- }
- patrolNodes := []libkg.Node{}
- if graphSummary != nil {
- patrolNodes = graphSummary.Nodes
- }
- patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
- patrolInput = libkg.OverlayResearchMap(
- patrolInput,
- researchMap.AudienceSummary,
- researchMap.Questions,
- researchMap.Pillars,
- researchMap.PatrolKeywords,
- )
- patrolKeywords = libkg.ResolveScanPatrolKeywords(
- stringSliceField(payload, "patrol_keywords"),
- researchMap.PatrolKeywords,
- patrolInput,
- libkg.NodesForPatrolKeywordDerivation(patrolNodes),
- )
- if len(patrolKeywords) == 0 {
- return fmt.Errorf("無法從產品與研究地圖產出足夠相關的搜尋關鍵字,請補充研究地圖或勾選圖譜節點")
- }
- }
-
- nodes := []libkg.Node{}
- if graphSummary != nil {
- nodes = graphSummary.Nodes
- }
- if !patrolMode {
- if graphSummary == nil {
- return fmt.Errorf("請先產生延伸知識圖譜,或改用手動海巡關鍵字")
- }
- if ids := stringSliceField(payload, "node_ids"); len(ids) > 0 {
- nodes = filterNodesByIDs(graphSummary.Nodes, ids)
- } else {
- nodes = selectedNodes(graphSummary.Nodes)
- }
- if len(nodes) == 0 {
- return fmt.Errorf("請先勾選要海巡的節點並儲存")
- }
- }
-
- research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
- if err != nil {
- return err
- }
- if testPatrol {
- if !memberCtx.BrowserConnected {
- return fmt.Errorf("測試海巡需先同步 Chrome Extension Session")
- }
- memberCtx = placement.MemberContextForCrawlerOnly(memberCtx)
- } else {
- if !memberCtx.AllowsBrave && !memberCtx.AllowsThreadsAPI && !memberCtx.AllowsCrawler {
- return fmt.Errorf("目前連線模式無法海巡,請確認 Threads API、Brave 或 Chrome Session 設定")
- }
- if placement.MemberNeedsWebSearchKey(memberCtx) && strings.TrimSpace(memberCtx.WebSearchAPIKey()) == "" {
- return fmt.Errorf("%s", placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{
- WebSearchProvider: memberCtx.WebSearchProvider,
- BraveAPIKey: memberCtx.BraveAPIKey,
- ExaAPIKey: memberCtx.ExaAPIKey,
- }))
- }
- if memberCtx.AllowsThreadsAPI && !memberCtx.ApiConnected {
- return fmt.Errorf("請先完成 Threads API 連線後再開始海巡")
- }
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "crawl",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- exclusions := append([]string{}, researchMap.Exclusions...)
- patrolInput := libkg.PatrolTagInputFromBrand(brand, strings.TrimSpace(brand.ProductBrief))
- if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
- patrolInput.ProductFeatures = formatted
- }
- patrolInput = libkg.OverlayResearchMap(
- patrolInput,
- researchMap.AudienceSummary,
- researchMap.Questions,
- researchMap.Pillars,
- researchMap.PatrolKeywords,
- )
- scanContext := placement.PostScanContextFromPatrolInput(patrolInput, exclusions)
-
- if testPatrol {
- updateProgress(fmt.Sprintf("測試海巡(爬蟲):依 %d 組關鍵字準備搜尋…", len(patrolKeywords)), 5)
- } else if len(patrolKeywords) > 0 {
- updateProgress(fmt.Sprintf("依 %d 組海巡關鍵字準備雙軌搜尋…", len(patrolKeywords)), 5)
- } else {
- updateProgress("準備置入海巡…", 5)
- }
-
- webClient := websearch.New(memberCtx.WebSearchConfig())
- crawlerFn := placement.WrapPoliteCrawler(makeCrawlerSearchFn(deps, tenantID, ownerUID))
- graphNodes := []libkg.Node{}
- if graphSummary != nil {
- graphNodes = graphSummary.Nodes
- }
- checkpointReq := scanpostusecase.CheckpointRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: brandID,
- TopicID: topicID,
- GraphID: graphID,
- ScanJobID: step.JobID,
- }
- candidates, err := placement.RunDualTrackDiscover(ctx, placement.DualTrackInput{
- Nodes: graphNodes,
- PatrolKeywords: patrolKeywords,
- Exclusions: exclusions,
- PatrolContext: scanContext,
- Member: memberCtx,
- WebSearch: webClient,
- Crawler: crawlerFn,
- OnCheckpoint: func(batch []placement.ScanCandidate) error {
- if len(batch) == 0 {
- return nil
- }
- checkpointReq.Posts = batch
- saved, err := deps.ScanPost.UpsertScanCheckpoint(ctx, checkpointReq)
- if err != nil {
- return err
- }
- updateProgress(fmt.Sprintf("已儲存 %d 篇候選貼文(邊爬邊存)", saved), -1)
- return nil
- },
- }, updateProgress)
- if err != nil {
- return err
- }
-
- scrapeReplies := memberCtx.ScrapeReplies
- if v, ok := payload["scrape_replies"].(bool); ok {
- scrapeReplies = v
- } else if memberCtx.ApiConnected && strings.TrimSpace(memberCtx.ThreadsAPIAccessToken) != "" {
- // Formal Threads API mode can fetch replies without browser session.
- scrapeReplies = true
- }
- if scrapeReplies {
- updateProgress("抓取高優先貼文留言…", 88)
- candidates = placement.AttachReplies(ctx, placement.ScrapeRepliesInput{
- Posts: candidates,
- Member: memberCtx,
- RepliesPerPost: memberCtx.RepliesPerPost,
- })
- }
-
- updateProgress(fmt.Sprintf("整理 %d 篇海巡結果…", len(candidates)), 92)
- count, err := deps.ScanPost.FinalizeScan(ctx, scanpostusecase.ReplaceRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- BrandID: brandID,
- TopicID: topicID,
- GraphID: graphID,
- ScanJobID: step.JobID,
- Posts: candidates,
- })
- if err != nil {
- return err
- }
-
- gold := 0
- recent := 0
- solved := 0
- replyCount := 0
- for _, item := range candidates {
- replyCount += len(item.Replies)
- if item.Priority == "gold" {
- gold++
- }
- if item.Priority == "gold" || item.Priority == "recent" {
- recent++
- }
- if item.SolvedByProduct {
- solved++
- }
- }
-
- summaryLabel := "雙軌海巡"
- discoverNote := fmt.Sprintf(
- "Threads API + %s Search API 雙管道搜尋;候選 %d 篇已寫入",
- memberCtx.WebSearchProviderLabel(),
- count,
- )
- if testPatrol {
- summaryLabel = "測試海巡(爬蟲)"
- discoverNote = fmt.Sprintf("Chrome 爬蟲搜尋;候選 %d 篇已寫入", count)
- }
- handoff := map[string]any{
- "flow": "placement",
- "brand_id": brandID,
- "summary": fmt.Sprintf(
- "%s完成:%d 篇(gold %d、近期軌 %d、產品可解 %d)",
- summaryLabel, count, gold, recent, solved,
- ),
- "pain_breakdown": map[string]any{
- "posts": count,
- "gold": gold,
- "recent_7d": recent,
- "solved_by_prod": solved,
- "replies": replyCount,
- },
- "discover_note": discoverNote,
- "test_patrol": testPatrol,
- "next_route": "/outreach?brand=" + brandID,
- "needs_supplemental_expand": false,
- "search_source_mode": string(memberCtx.SearchSourceMode),
- "dev_mode": memberCtx.DevMode,
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "post_count": count,
- "gold_count": gold,
- "recent_count": recent,
- "solved_count": solved,
- "reply_count": replyCount,
- "search_source_mode": string(memberCtx.SearchSourceMode),
- "handoff": handoff,
- },
- })
- return err
-}
-
-func selectedNodes(nodes []libkg.Node) []libkg.Node {
- out := make([]libkg.Node, 0, len(nodes))
- for _, node := range nodes {
- if node.SelectedForScan {
- out = append(out, node)
- }
- }
- return out
-}
-
-func filterNodesByIDs(nodes []libkg.Node, ids []string) []libkg.Node {
- allowed := map[string]struct{}{}
- for _, id := range ids {
- id = strings.TrimSpace(id)
- if id != "" {
- allowed[id] = struct{}{}
- }
- }
- out := make([]libkg.Node, 0, len(ids))
- for _, node := range nodes {
- if _, ok := allowed[node.ID]; ok {
- out = append(out, node)
- }
- }
- return out
-}
-
-func isKnowledgeGraphNotFound(err error) bool {
- if err == nil {
- return false
- }
- if e := app.FromError(err); e != nil && e.Category() == code.ResNotFound {
- return true
- }
- return strings.Contains(strings.ToLower(err.Error()), "knowledge graph not found")
-}
-
-func stringSliceField(payload map[string]any, key string) []string {
- if payload == nil {
- return nil
- }
- raw, ok := payload[key]
- if !ok || raw == nil {
- return nil
- }
- switch v := raw.(type) {
- case []string:
- return v
- case []any:
- out := make([]string, 0, len(v))
- for _, item := range v {
- if s, ok := item.(string); ok {
- out = append(out, s)
- }
- }
- return out
- default:
- return nil
- }
-}
diff --git a/old/backend/internal/worker/job/scan_viral.go b/old/backend/internal/worker/job/scan_viral.go
deleted file mode 100644
index e50fbcd..0000000
--- a/old/backend/internal/worker/job/scan_viral.go
+++ /dev/null
@@ -1,516 +0,0 @@
-package job
-
-import (
- "context"
- "fmt"
- "strings"
-
- "haixun-backend/internal/library/clock"
- app "haixun-backend/internal/library/errors"
- "haixun-backend/internal/library/errors/code"
- "haixun-backend/internal/library/placement"
- libviral "haixun-backend/internal/library/viral"
- "haixun-backend/internal/library/websearch"
- domai "haixun-backend/internal/model/ai/domain/usecase"
- aiusecase "haixun-backend/internal/model/ai/usecase"
- copydraftusecase "haixun-backend/internal/model/copy_draft/domain/usecase"
- missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
- missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- personaentity "haixun-backend/internal/model/persona/domain/entity"
- personadomain "haixun-backend/internal/model/persona/domain/usecase"
- placementusecase "haixun-backend/internal/model/placement/usecase"
- scanpostusecase "haixun-backend/internal/model/scan_post/domain/usecase"
- accountentity "haixun-backend/internal/model/threads_account/domain/entity"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-type ScanViralDeps struct {
- Jobs jobdom.UseCase
- CopyMission missiondomain.UseCase
- Persona personadomain.UseCase
- ScanPost scanpostusecase.UseCase
- CopyDraft copydraftusecase.UseCase
- ThreadsAccount threadsaccountdomain.UseCase
- Placement placementusecase.UseCase
- AI aiusecase.UseCase
-}
-
-func RegisterScanViralHandler(runner *Runner, deps ScanViralDeps) {
- if runner == nil {
- return
- }
- runner.RegisterStepHandler("viral_crawl", func(ctx context.Context, step StepContext) error {
- return runScanViral(ctx, step, deps)
- })
-}
-
-func runScanViral(ctx context.Context, step StepContext, deps ScanViralDeps) error {
- payload := step.Run.Payload
- tenantID, ownerUID := runActorFromPayload(payload, step.Run)
- personaID := personaIDFromPayload(payload)
- missionID := copyMissionIDFromPayload(payload)
- if tenantID == "" || ownerUID == "" || personaID == "" {
- return fmt.Errorf("scan-viral payload missing tenant_id, owner_uid, or persona_id")
- }
-
- persona, err := deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return err
- }
- var mission *missiondomain.MissionSummary
- if missionID != "" {
- item, err := deps.CopyMission.Get(ctx, tenantID, ownerUID, personaID, missionID)
- if err != nil {
- return err
- }
- mission = item
- }
-
- research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
- if err != nil {
- return err
- }
- if !memberCtx.HasDiscoverPath() {
- return fmt.Errorf("爆款掃描需要 Threads API、Chrome Session 或 Web Search API(請檢查連線模式與搜尋來源)")
- }
-
- updateProgress := func(summary string, percentage int) {
- _ = step.Heartbeat(ctx)
- _, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Phase: "viral_crawl",
- Summary: summary,
- Percentage: percentage,
- })
- }
-
- bootstrap := boolField(payload, "bootstrap")
- if mission == nil && bootstrap && persona.CopyResearchMap.AudienceSummary == "" && len(persona.CopyResearchMap.SuggestedTags) == 0 {
- updateProgress("產生拷貝忍者研究地圖…", 8)
- if err := ensureCopyResearchMap(ctx, deps, tenantID, ownerUID, persona, memberCtx, updateProgress); err != nil {
- return err
- }
- persona, err = deps.Persona.Get(ctx, tenantID, ownerUID, personaID)
- if err != nil {
- return err
- }
- }
-
- keywords := stringSliceField(payload, "keywords")
- exclusions := persona.CopyResearchMap.Exclusions
- missionScan := mission != nil
- if missionScan {
- if len(keywords) == 0 {
- keywords = append([]string(nil), mission.SelectedTags...)
- }
- exclusions = mission.ResearchMap.Exclusions
- if len(keywords) == 0 {
- return fmt.Errorf("請先產生研究地圖並勾選搜尋標籤")
- }
- } else if len(keywords) == 0 {
- keywords = deriveViralKeywords(persona)
- }
- if len(keywords) == 0 {
- return fmt.Errorf("請提供爆款掃描關鍵字,或先完成研究地圖/對標帳號")
- }
-
- if missionScan && missionID != "" {
- updateProgress("清除舊爆款與產文草稿…", 8)
- if deps.ScanPost != nil {
- if err := deps.ScanPost.ClearCopyMissionViralScan(ctx, tenantID, ownerUID, personaID, missionID); err != nil {
- return err
- }
- }
- if deps.CopyDraft != nil {
- if err := deps.CopyDraft.ClearByMission(ctx, tenantID, ownerUID, personaID, missionID); err != nil {
- return err
- }
- }
- }
-
- updateProgress("準備爆款掃描…", 12)
- crawlerFn := makeCrawlerSearchFn(ScanPlacementDeps{ThreadsAccount: deps.ThreadsAccount}, tenantID, ownerUID)
- candidates, err := libviral.RunDiscover(ctx, libviral.DiscoverInput{
- Keywords: keywords,
- Exclusions: exclusions,
- SeedQuery: missionSeedQuery(mission),
- Label: missionLabel(mission),
- TopicHints: missionTopicHints(mission),
- Member: memberCtx,
- Crawler: crawlerFn,
- MissionScan: missionScan,
- }, updateProgress)
- if err != nil {
- return err
- }
-
- if missionScan && memberCtx.ScrapeReplies && memberCtx.ApiConnected {
- updateProgress("收集高互動留言樣本…", 88)
- candidates = placement.AttachReplies(ctx, placement.ScrapeRepliesInput{
- Posts: candidates,
- Member: memberCtx,
- RepliesPerPost: memberCtx.RepliesPerPost,
- MaxPosts: 6,
- })
- }
-
- updateProgress(fmt.Sprintf("寫入 %d 篇爆款候選…", len(candidates)), 92)
- count, err := deps.ScanPost.ReplaceFromViralScan(ctx, scanpostusecase.ViralReplaceRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- CopyMissionID: missionID,
- ScanJobID: step.JobID,
- Posts: candidates,
- })
- if err != nil {
- return err
- }
-
- if missionScan && missionID != "" && mission != nil {
- scanned := missionentity.StatusScanned
- jobID := step.JobID
- prevSimilar := similarAccountsFromSummary(mission.ResearchMap.SimilarAccounts)
- var knownAccounts map[string]accountentity.KnownAccountProfile
- if memberCtx.ActiveAccountID != "" && deps.ThreadsAccount != nil {
- knownAccounts, _ = deps.ThreadsAccount.GetKnownAccounts(ctx, tenantID, ownerUID, memberCtx.ActiveAccountID)
- }
- excluded := libviral.ExcludedSimilarAccountUsernames(prevSimilar)
- excluded = append(excluded, libviral.ExcludedKnownAccountUsernames(knownAccounts)...)
- referenceAccounts := libviral.BuildReferenceAccountsFromScan(libviral.ReferenceAccountInput{
- SeedQuery: mission.SeedQuery,
- Label: mission.Label,
- Posts: candidates,
- Limit: libviral.MaxSimilarAccounts,
- ExcludedUsernames: excluded,
- })
- // Web-search fallback: only when scan surfaced fewer than
- // MinSimilarAccountsBeforeWebSupplement (5) reference authors — avoids
- // extra search API calls when scan data is already sufficient.
- // (site:threads.net "seed" + pillars). Failures are logged-quiet and must
- // not fail the scan job — the scan-derived accounts are still valid.
- if len(referenceAccounts) < libviral.MinSimilarAccountsBeforeWebSupplement && placement.WebSearchAvailable(research) {
- referenceAccounts = supplementSimilarAccountsFromWeb(ctx, referenceAccounts, memberCtx, mission)
- }
- entityTags := make([]missionentity.SuggestedTag, 0, len(mission.ResearchMap.SuggestedTags))
- for _, tag := range mission.ResearchMap.SuggestedTags {
- entityTags = append(entityTags, missionentity.SuggestedTag{
- Tag: tag.Tag,
- Reason: tag.Reason,
- SearchIntent: tag.SearchIntent,
- SearchType: tag.SearchType,
- })
- }
- mergedSimilar := libviral.MergeSimilarAccounts(prevSimilar, referenceAccounts)
- mergedSimilar = libviral.ApplyKnownAccountMemory(mergedSimilar, knownAccounts)
- if memberCtx.ActiveAccountID != "" && deps.ThreadsAccount != nil {
- _ = deps.ThreadsAccount.UpsertKnownAccountsFromScan(ctx, threadsaccountdomain.UpsertKnownAccountsFromScanRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- AccountID: memberCtx.ActiveAccountID,
- MissionID: missionID,
- PersonaID: personaID,
- SelectedTags: mission.SelectedTags,
- SimilarAccounts: mergedSimilar,
- })
- }
- var audienceSamples []missionentity.AudienceSample
- if memberCtx.ApiConnected && memberCtx.ScrapeReplies {
- var allReplies []placement.ReplyCandidate
- for _, post := range candidates {
- allReplies = append(allReplies, post.Replies...)
- }
- if len(allReplies) > 0 {
- audienceSamples = libviral.MergeAudienceSamples(
- audienceSamplesFromSummary(mission.ResearchMap.AudienceSamples),
- libviral.BuildAudienceSamplesFromReplies(allReplies, libviral.AudienceOpts{
- Max: libviral.MaxAudienceSamples,
- ExcludeAuthors: libviral.SimilarAccountUsernames(mergedSimilar),
- Now: clock.NowUnixNano(),
- }),
- )
- }
- }
- updatedMap := missionentity.ResearchMap{
- AudienceSummary: mission.ResearchMap.AudienceSummary,
- ContentGoal: mission.ResearchMap.ContentGoal,
- Questions: append([]string(nil), mission.ResearchMap.Questions...),
- Pillars: append([]string(nil), mission.ResearchMap.Pillars...),
- Exclusions: append([]string(nil), mission.ResearchMap.Exclusions...),
- SuggestedTags: entityTags,
- SimilarAccounts: mergedSimilar,
- AudienceSamples: audienceSamples,
- BenchmarkNotes: mission.ResearchMap.BenchmarkNotes,
- }
- _, _ = deps.CopyMission.Update(ctx, missiondomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: personaID,
- MissionID: missionID,
- Patch: missiondomain.MissionPatch{
- LastScanJobID: &jobID,
- Status: &scanned,
- ResearchMap: &updatedMap,
- },
- })
- }
-
- nextRoute := "/matrix"
- if missionID != "" {
- nextRoute = fmt.Sprintf("/matrix/missions/%s", missionID)
- }
- handoff := map[string]any{
- "flow": "copy",
- "persona_id": personaID,
- "copy_mission_id": missionID,
- "summary": fmt.Sprintf("爆款掃描完成:%d 篇候選", count),
- "next_route": nextRoute,
- }
-
- _, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
- JobID: step.JobID,
- WorkerID: step.WorkerID,
- Result: map[string]any{
- "post_count": count,
- "handoff": handoff,
- },
- })
- return err
-}
-
-func ensureCopyResearchMap(
- ctx context.Context,
- deps ScanViralDeps,
- tenantID, ownerUID string,
- persona *personadomain.PersonaSummary,
- memberCtx placement.MemberContext,
- updateProgress func(string, int),
-) error {
- if persona == nil {
- return fmt.Errorf("copy research map: missing persona")
- }
- credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
- if err != nil {
- return err
- }
- providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
- if err != nil {
- return err
- }
-
- label := strings.TrimSpace(persona.DisplayName)
- if label == "" {
- label = "拷貝主題"
- }
- seed := strings.TrimSpace(persona.SeedQuery)
- if seed == "" {
- seed = strings.TrimPrefix(strings.TrimSpace(persona.StyleBenchmark), "@")
- }
- if seed == "" {
- for _, line := range strings.Split(strings.TrimSpace(persona.Brief), "\n") {
- line = strings.TrimSpace(line)
- if line != "" {
- seed = line
- break
- }
- }
- }
-
- result, err := deps.AI.GenerateText(ctx, domai.GenerateRequest{
- Provider: providerID,
- Model: credential.Model,
- Credential: domai.Credential{
- APIKey: credential.APIKey,
- },
- System: libviral.BuildCopyResearchMapSystemPrompt(),
- Messages: []domai.Message{
- {
- Role: "user",
- Content: libviral.BuildCopyResearchMapUserPrompt(libviral.CopyResearchMapInput{
- Label: label,
- SeedQuery: seed,
- Brief: persona.Brief,
- Persona: persona.Persona,
- StyleBenchmark: persona.StyleBenchmark,
- }),
- },
- },
- })
- if err != nil {
- return err
- }
-
- parsed, err := libviral.ParseCopyResearchMapOutput(result.Text)
- if err != nil {
- return app.For(code.AI).SvcThirdParty("拷貝研究地圖 LLM 回傳無法解析:" + err.Error())
- }
-
- entityMap := personaentity.CopyResearchMap{
- AudienceSummary: parsed.AudienceSummary,
- ContentGoal: parsed.ContentGoal,
- Questions: parsed.Questions,
- Pillars: parsed.Pillars,
- Exclusions: parsed.Exclusions,
- SuggestedTags: parsed.SuggestedTags,
- BenchmarkNotes: parsed.BenchmarkNotes,
- }
- patch := personadomain.PersonaPatch{
- CopyResearchMap: &entityMap,
- }
- if seed != "" && strings.TrimSpace(persona.SeedQuery) == "" {
- patch.SeedQuery = &seed
- }
- _, err = deps.Persona.Update(ctx, personadomain.UpdateRequest{
- TenantID: tenantID,
- OwnerUID: ownerUID,
- PersonaID: persona.ID,
- Patch: patch,
- })
- if err != nil {
- return err
- }
- if updateProgress != nil {
- updateProgress("研究地圖已就緒", 10)
- }
- _ = memberCtx
- return nil
-}
-
-func personaIDFromPayload(payload map[string]any) string {
- if id := stringField(payload, "persona_id"); id != "" {
- return id
- }
- return stringField(payload, "scope_id")
-}
-
-func missionSeedQuery(mission *missiondomain.MissionSummary) string {
- if mission == nil {
- return ""
- }
- return mission.SeedQuery
-}
-
-func missionLabel(mission *missiondomain.MissionSummary) string {
- if mission == nil {
- return ""
- }
- return mission.Label
-}
-
-func missionTopicHints(mission *missiondomain.MissionSummary) []string {
- if mission == nil {
- return nil
- }
- out := []string{}
- seen := map[string]struct{}{}
- add := func(item string) {
- item = strings.TrimSpace(item)
- if item == "" {
- return
- }
- if _, ok := seen[item]; ok {
- return
- }
- seen[item] = struct{}{}
- out = append(out, item)
- }
- for _, tag := range mission.SelectedTags {
- add(tag)
- }
- for _, tag := range mission.ResearchMap.SuggestedTags {
- add(tag.Tag)
- }
- for _, pillar := range mission.ResearchMap.Pillars {
- add(pillar)
- }
- for _, question := range mission.ResearchMap.Questions {
- add(question)
- }
- return out
-}
-
-func deriveViralKeywords(persona *personadomain.PersonaSummary) []string {
- if persona == nil {
- return nil
- }
- out := []string{}
- seen := map[string]struct{}{}
- add := func(kw string) {
- kw = strings.TrimSpace(kw)
- if kw == "" {
- return
- }
- if _, ok := seen[kw]; ok {
- return
- }
- seen[kw] = struct{}{}
- out = append(out, kw)
- }
- for _, tag := range persona.CopyResearchMap.SuggestedTags {
- add(tag)
- }
- for _, q := range persona.CopyResearchMap.Questions {
- add(q)
- }
- if bench := strings.TrimPrefix(strings.TrimSpace(persona.StyleBenchmark), "@"); bench != "" {
- add(bench)
- }
- for _, line := range strings.Split(strings.TrimSpace(persona.Brief), "\n") {
- line = strings.TrimSpace(line)
- if line != "" {
- add(line)
- break
- }
- }
- return out
-}
-
-// supplementSimilarAccountsFromWeb asks the configured web search provider
-// for threads.net profiles matching the mission seed/brief/pillars, then
-// merges the results with the scan-derived accounts via EnrichSimilarAccounts.
-// It must never return an error: web search outage is non-fatal. Confidence is
-// upgraded to "high" for usernames that appear in both sources (scan+web).
-func supplementSimilarAccountsFromWeb(
- ctx context.Context,
- scanAccounts []missionentity.SimilarAccount,
- memberCtx placement.MemberContext,
- mission *missiondomain.MissionSummary,
-) []missionentity.SimilarAccount {
- if memberCtx.WebSearchAPIKey() == "" {
- return scanAccounts
- }
- client := websearch.New(memberCtx.WebSearchConfig())
- if !client.Enabled() {
- return scanAccounts
- }
- webAccounts, err := libviral.DiscoverSimilarAccounts(ctx, client, libviral.DiscoverAccountsInput{
- SeedQuery: mission.SeedQuery,
- Brief: mission.Brief,
- Pillars: mission.ResearchMap.Pillars,
- })
- if err != nil || len(webAccounts) == 0 {
- return scanAccounts
- }
- entityWeb := make([]missionentity.SimilarAccount, 0, len(webAccounts))
- for _, w := range webAccounts {
- matched := w.MatchedSource
- if len(matched) == 0 {
- matched = []string{"web"}
- }
- entityWeb = append(entityWeb, missionentity.SimilarAccount{
- Username: w.Username,
- Reason: w.Reason,
- Source: "web",
- MatchedSource: matched,
- Confidence: w.Confidence,
- ProfileURL: w.ProfileURL,
- })
- }
- return libviral.MergeSimilarAccounts(scanAccounts, entityWeb)
-}
diff --git a/old/backend/internal/worker/job/scheduler.go b/old/backend/internal/worker/job/scheduler.go
deleted file mode 100644
index a6479ce..0000000
--- a/old/backend/internal/worker/job/scheduler.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package job
-
-import (
- "context"
- "log"
- "time"
-
- domusecase "haixun-backend/internal/model/job/domain/usecase"
-)
-
-type Scheduler struct {
- holder string
- jobs domusecase.UseCase
-}
-
-func NewScheduler(holder string, jobs domusecase.UseCase) *Scheduler {
- return &Scheduler{holder: holder, jobs: jobs}
-}
-
-func (s *Scheduler) Start(ctx context.Context, interval time.Duration) {
- if interval <= 0 {
- interval = time.Minute
- }
- log.Printf("job scheduler started: holder=%s interval=%s", s.holder, interval)
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- log.Printf("job scheduler stopped: holder=%s", s.holder)
- return
- case <-ticker.C:
- created, err := s.jobs.RunSchedulerTick(ctx, s.holder)
- if err != nil {
- log.Printf("job scheduler tick error: %v", err)
- continue
- }
- if created > 0 {
- log.Printf("job scheduler created %d run(s)", created)
- }
- }
- }
-}
diff --git a/old/backend/internal/worker/job/threads_token_sweeper.go b/old/backend/internal/worker/job/threads_token_sweeper.go
deleted file mode 100644
index 6114037..0000000
--- a/old/backend/internal/worker/job/threads_token_sweeper.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package job
-
-import (
- "context"
- "log"
- "time"
-
- jobdom "haixun-backend/internal/model/job/domain/usecase"
- threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
-)
-
-const refreshThreadsTokenTemplateType = "refresh-threads-token"
-
-type ThreadsTokenSweeper struct {
- accounts threadsaccountdomain.UseCase
- jobs jobdom.UseCase
- leadTime time.Duration
-}
-
-func NewThreadsTokenSweeper(
- accounts threadsaccountdomain.UseCase,
- jobs jobdom.UseCase,
- leadTime time.Duration,
-) *ThreadsTokenSweeper {
- if leadTime <= 0 {
- leadTime = 7 * 24 * time.Hour
- }
- return &ThreadsTokenSweeper{
- accounts: accounts,
- jobs: jobs,
- leadTime: leadTime,
- }
-}
-
-func (s *ThreadsTokenSweeper) Start(ctx context.Context, interval time.Duration) {
- if s == nil || s.accounts == nil || s.jobs == nil {
- return
- }
- if interval <= 0 {
- interval = time.Hour
- }
- log.Printf("threads token sweeper started: interval=%s lead_time=%s", interval, s.leadTime)
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- log.Printf("threads token sweeper stopped")
- return
- case <-ticker.C:
- created, err := s.tick(ctx)
- if err != nil {
- log.Printf("threads token sweeper tick error: %v", err)
- continue
- }
- if created > 0 {
- log.Printf("threads token sweeper enqueued %d refresh job(s)", created)
- }
- }
- }
-}
-
-func (s *ThreadsTokenSweeper) tick(ctx context.Context) (int, error) {
- candidates, err := s.accounts.ListTokenRefreshCandidates(ctx, s.leadTime)
- if err != nil {
- return 0, err
- }
- created := 0
- for _, item := range candidates {
- if item.AccountID == "" || item.TenantID == "" || item.OwnerUID == "" {
- continue
- }
- _, err := s.jobs.CreateRun(ctx, jobdom.CreateRunRequest{
- TemplateType: refreshThreadsTokenTemplateType,
- Scope: "threads_account",
- ScopeID: item.AccountID,
- TenantID: item.TenantID,
- OwnerUID: item.OwnerUID,
- Payload: map[string]any{
- "tenant_id": item.TenantID,
- "owner_uid": item.OwnerUID,
- "account_id": item.AccountID,
- "expires_at": item.ExpiresAt,
- },
- })
- if err != nil {
- log.Printf("threads token sweeper skip account=%s: %v", item.AccountID, err)
- continue
- }
- created++
- }
- return created, nil
-}
diff --git a/old/backend/web/Dockerfile b/old/backend/web/Dockerfile
deleted file mode 100644
index 12ace45..0000000
--- a/old/backend/web/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM node:22-alpine AS build
-
-WORKDIR /src
-COPY backend/web/package*.json ./
-RUN npm ci
-COPY backend/web/ ./
-RUN npm run build
-
-FROM nginx:1.27-alpine
-
-COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
-COPY --from=build /src/dist /usr/share/nginx/html
-EXPOSE 80
diff --git a/old/backend/web/index.html b/old/backend/web/index.html
deleted file mode 100644
index 212d61b..0000000
--- a/old/backend/web/index.html
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
- 巡樓 Console
-
-
-
-
-
-
-
-
-
-
diff --git a/old/backend/web/package-lock.json b/old/backend/web/package-lock.json
deleted file mode 100644
index 1d5090e..0000000
--- a/old/backend/web/package-lock.json
+++ /dev/null
@@ -1,1718 +0,0 @@
-{
- "name": "haixun-console-web",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "haixun-console-web",
- "version": "0.1.0",
- "dependencies": {
- "@vitejs/plugin-react": "^4.3.4",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "taipei-sans-tc": "^0.1.1",
- "typescript": "^5.8.2",
- "vite": "^6.2.0"
- },
- "devDependencies": {
- "@types/react": "^19.0.10",
- "@types/react-dom": "^19.0.4"
- }
- },
- "node_modules/@babel/code-frame": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
- "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-validator-identifier": "^7.29.7",
- "js-tokens": "^4.0.0",
- "picocolors": "^1.1.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/compat-data": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
- "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/core": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
- "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-compilation-targets": "^7.29.7",
- "@babel/helper-module-transforms": "^7.29.7",
- "@babel/helpers": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/remapping": "^2.3.5",
- "convert-source-map": "^2.0.0",
- "debug": "^4.1.0",
- "gensync": "^1.0.0-beta.2",
- "json5": "^2.2.3",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/babel"
- }
- },
- "node_modules/@babel/generator": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
- "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7",
- "@jridgewell/gen-mapping": "^0.3.12",
- "@jridgewell/trace-mapping": "^0.3.28",
- "jsesc": "^3.0.2"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-compilation-targets": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
- "license": "MIT",
- "dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-globals": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
- "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-imports": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
- "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
- "license": "MIT",
- "dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-module-transforms": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0"
- }
- },
- "node_modules/@babel/helper-plugin-utils": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
- "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-string-parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-option": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
- "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helpers": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
- "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
- "license": "MIT",
- "dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
- "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.7"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-self": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
- "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/plugin-transform-react-jsx-source": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
- "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-plugin-utils": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- },
- "peerDependencies": {
- "@babel/core": "^7.0.0-0"
- }
- },
- "node_modules/@babel/template": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
- "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/types": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/traverse": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
- "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
- "license": "MIT",
- "dependencies": {
- "@babel/code-frame": "^7.29.7",
- "@babel/generator": "^7.29.7",
- "@babel/helper-globals": "^7.29.7",
- "@babel/parser": "^7.29.7",
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7",
- "debug": "^4.3.1"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
- "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
- "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
- "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
- "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
- "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
- "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
- "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
- "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
- "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
- "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
- "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
- "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
- "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
- "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
- "cpu": [
- "mips64el"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
- "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
- "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
- "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
- "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
- "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
- "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
- "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
- "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
- "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
- "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
- "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
- "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.13",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
- "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/remapping": {
- "version": "2.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
- "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.27",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
- "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
- "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
- "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
- "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
- "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
- "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
- "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
- "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
- "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
- "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
- "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
- "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
- "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
- "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
- "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
- "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
- "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
- "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
- "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
- "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-openbsd-x64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
- "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ]
- },
- "node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
- "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ]
- },
- "node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
- "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
- "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
- "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
- "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@types/babel__core": {
- "version": "7.20.5",
- "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
- "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.20.7",
- "@babel/types": "^7.20.7",
- "@types/babel__generator": "*",
- "@types/babel__template": "*",
- "@types/babel__traverse": "*"
- }
- },
- "node_modules/@types/babel__generator": {
- "version": "7.27.0",
- "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
- "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__template": {
- "version": "7.4.4",
- "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
- "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.1.0",
- "@babel/types": "^7.0.0"
- }
- },
- "node_modules/@types/babel__traverse": {
- "version": "7.28.0",
- "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
- "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.28.2"
- }
- },
- "node_modules/@types/estree": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
- "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
- "license": "MIT"
- },
- "node_modules/@types/react": {
- "version": "19.2.17",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
- "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "csstype": "^3.2.2"
- }
- },
- "node_modules/@types/react-dom": {
- "version": "19.2.3",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
- "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
- "dev": true,
- "license": "MIT",
- "peerDependencies": {
- "@types/react": "^19.2.0"
- }
- },
- "node_modules/@vitejs/plugin-react": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
- "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
- "license": "MIT",
- "dependencies": {
- "@babel/core": "^7.28.0",
- "@babel/plugin-transform-react-jsx-self": "^7.27.1",
- "@babel/plugin-transform-react-jsx-source": "^7.27.1",
- "@rolldown/pluginutils": "1.0.0-beta.27",
- "@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
- },
- "engines": {
- "node": "^14.18.0 || >=16.0.0"
- },
- "peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
- }
- },
- "node_modules/baseline-browser-mapping": {
- "version": "2.10.40",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
- "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
- "license": "Apache-2.0",
- "bin": {
- "baseline-browser-mapping": "dist/cli.cjs"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/browserslist": {
- "version": "4.28.4",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
- "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "baseline-browser-mapping": "^2.10.38",
- "caniuse-lite": "^1.0.30001799",
- "electron-to-chromium": "^1.5.376",
- "node-releases": "^2.0.48",
- "update-browserslist-db": "^1.2.3"
- },
- "bin": {
- "browserslist": "cli.js"
- },
- "engines": {
- "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
- }
- },
- "node_modules/caniuse-lite": {
- "version": "1.0.30001799",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
- "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "CC-BY-4.0"
- },
- "node_modules/convert-source-map": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
- "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
- "license": "MIT"
- },
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/electron-to-chromium": {
- "version": "1.5.381",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
- "integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
- "license": "ISC"
- },
- "node_modules/esbuild": {
- "version": "0.25.12",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
- "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
- "hasInstallScript": true,
- "license": "MIT",
- "bin": {
- "esbuild": "bin/esbuild"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "@esbuild/aix-ppc64": "0.25.12",
- "@esbuild/android-arm": "0.25.12",
- "@esbuild/android-arm64": "0.25.12",
- "@esbuild/android-x64": "0.25.12",
- "@esbuild/darwin-arm64": "0.25.12",
- "@esbuild/darwin-x64": "0.25.12",
- "@esbuild/freebsd-arm64": "0.25.12",
- "@esbuild/freebsd-x64": "0.25.12",
- "@esbuild/linux-arm": "0.25.12",
- "@esbuild/linux-arm64": "0.25.12",
- "@esbuild/linux-ia32": "0.25.12",
- "@esbuild/linux-loong64": "0.25.12",
- "@esbuild/linux-mips64el": "0.25.12",
- "@esbuild/linux-ppc64": "0.25.12",
- "@esbuild/linux-riscv64": "0.25.12",
- "@esbuild/linux-s390x": "0.25.12",
- "@esbuild/linux-x64": "0.25.12",
- "@esbuild/netbsd-arm64": "0.25.12",
- "@esbuild/netbsd-x64": "0.25.12",
- "@esbuild/openbsd-arm64": "0.25.12",
- "@esbuild/openbsd-x64": "0.25.12",
- "@esbuild/openharmony-arm64": "0.25.12",
- "@esbuild/sunos-x64": "0.25.12",
- "@esbuild/win32-arm64": "0.25.12",
- "@esbuild/win32-ia32": "0.25.12",
- "@esbuild/win32-x64": "0.25.12"
- }
- },
- "node_modules/escalade": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
- "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/fdir": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
- "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
- "license": "MIT",
- "engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "picomatch": "^3 || ^4"
- },
- "peerDependenciesMeta": {
- "picomatch": {
- "optional": true
- }
- }
- },
- "node_modules/fsevents": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
- "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/gensync": {
- "version": "1.0.0-beta.2",
- "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
- "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "license": "MIT"
- },
- "node_modules/jsesc": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
- "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
- "license": "MIT",
- "bin": {
- "jsesc": "bin/jsesc"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/json5": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
- "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
- "license": "MIT",
- "bin": {
- "json5": "lib/cli.js"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/lru-cache": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
- "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
- "license": "ISC",
- "dependencies": {
- "yallist": "^3.0.2"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/nanoid": {
- "version": "3.3.15",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
- "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "bin": {
- "nanoid": "bin/nanoid.cjs"
- },
- "engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
- }
- },
- "node_modules/node-releases": {
- "version": "2.0.50",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
- "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/picocolors": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
- "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "license": "ISC"
- },
- "node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/jonschlinkert"
- }
- },
- "node_modules/postcss": {
- "version": "8.5.16",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
- "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/postcss"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "nanoid": "^3.3.12",
- "picocolors": "^1.1.1",
- "source-map-js": "^1.2.1"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- }
- },
- "node_modules/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
- "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/react-dom": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
- "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
- "license": "MIT",
- "dependencies": {
- "scheduler": "^0.27.0"
- },
- "peerDependencies": {
- "react": "^19.2.7"
- }
- },
- "node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/rollup": {
- "version": "4.62.2",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
- "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
- "license": "MIT",
- "dependencies": {
- "@types/estree": "1.0.9"
- },
- "bin": {
- "rollup": "dist/bin/rollup"
- },
- "engines": {
- "node": ">=18.0.0",
- "npm": ">=8.0.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.62.2",
- "@rollup/rollup-android-arm64": "4.62.2",
- "@rollup/rollup-darwin-arm64": "4.62.2",
- "@rollup/rollup-darwin-x64": "4.62.2",
- "@rollup/rollup-freebsd-arm64": "4.62.2",
- "@rollup/rollup-freebsd-x64": "4.62.2",
- "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
- "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
- "@rollup/rollup-linux-arm64-gnu": "4.62.2",
- "@rollup/rollup-linux-arm64-musl": "4.62.2",
- "@rollup/rollup-linux-loong64-gnu": "4.62.2",
- "@rollup/rollup-linux-loong64-musl": "4.62.2",
- "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
- "@rollup/rollup-linux-ppc64-musl": "4.62.2",
- "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
- "@rollup/rollup-linux-riscv64-musl": "4.62.2",
- "@rollup/rollup-linux-s390x-gnu": "4.62.2",
- "@rollup/rollup-linux-x64-gnu": "4.62.2",
- "@rollup/rollup-linux-x64-musl": "4.62.2",
- "@rollup/rollup-openbsd-x64": "4.62.2",
- "@rollup/rollup-openharmony-arm64": "4.62.2",
- "@rollup/rollup-win32-arm64-msvc": "4.62.2",
- "@rollup/rollup-win32-ia32-msvc": "4.62.2",
- "@rollup/rollup-win32-x64-gnu": "4.62.2",
- "@rollup/rollup-win32-x64-msvc": "4.62.2",
- "fsevents": "~2.3.2"
- }
- },
- "node_modules/scheduler": {
- "version": "0.27.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
- "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT"
- },
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
- "node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/taipei-sans-tc": {
- "version": "0.1.1",
- "resolved": "https://registry.npmjs.org/taipei-sans-tc/-/taipei-sans-tc-0.1.1.tgz",
- "integrity": "sha512-GCvdO+SATITfm128bT+fFmbpVW8+o0zLMVlNskSOk3CBghNzfqj4tvwTbIQ9vmvftd31s5UNypLA3aIw/IG6RQ==",
- "license": "MIT"
- },
- "node_modules/tinyglobby": {
- "version": "0.2.17",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
- "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
- "license": "MIT",
- "dependencies": {
- "fdir": "^6.5.0",
- "picomatch": "^4.0.4"
- },
- "engines": {
- "node": ">=12.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/SuperchupuDev"
- }
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/update-browserslist-db": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
- "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/browserslist"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/browserslist"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "escalade": "^3.2.0",
- "picocolors": "^1.1.1"
- },
- "bin": {
- "update-browserslist-db": "cli.js"
- },
- "peerDependencies": {
- "browserslist": ">= 4.21.0"
- }
- },
- "node_modules/vite": {
- "version": "6.4.3",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
- "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
- "license": "MIT",
- "dependencies": {
- "esbuild": "^0.25.0",
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
- },
- "bin": {
- "vite": "bin/vite.js"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://github.com/vitejs/vite?sponsor=1"
- },
- "optionalDependencies": {
- "fsevents": "~2.3.3"
- },
- "peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "jiti": ">=1.21.0",
- "less": "*",
- "lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "jiti": {
- "optional": true
- },
- "less": {
- "optional": true
- },
- "lightningcss": {
- "optional": true
- },
- "sass": {
- "optional": true
- },
- "sass-embedded": {
- "optional": true
- },
- "stylus": {
- "optional": true
- },
- "sugarss": {
- "optional": true
- },
- "terser": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "node_modules/yallist": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
- "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
- "license": "ISC"
- }
- }
-}
diff --git a/old/backend/web/package.json b/old/backend/web/package.json
deleted file mode 100644
index 686eb95..0000000
--- a/old/backend/web/package.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "name": "haixun-console-web",
- "private": true,
- "version": "0.1.0",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "tsc -b && vite build",
- "preview": "vite preview"
- },
- "dependencies": {
- "@vitejs/plugin-react": "^4.3.4",
- "vite": "^6.2.0",
- "typescript": "^5.8.2",
- "react": "^19.0.0",
- "react-dom": "^19.0.0",
- "taipei-sans-tc": "^0.1.1"
- },
- "devDependencies": {
- "@types/react": "^19.0.10",
- "@types/react-dom": "^19.0.4"
- }
-}
diff --git a/old/backend/web/src/App.tsx b/old/backend/web/src/App.tsx
deleted file mode 100644
index cdff08a..0000000
--- a/old/backend/web/src/App.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { useEffect, useState } from "react";
-import { api } from "./api/haixun";
-import { useAuth } from "./auth/AuthContext";
-import { Layout } from "./components/Layout";
-import { Button, Card, PageTitle } from "./components/ui";
-import { AuthPage } from "./pages/AuthPage";
-import { EmailVerifyPage } from "./pages/EmailVerifyPage";
-import { needsNativeEmailVerification } from "./lib/emailVerification";
-import { AccountsPage } from "./pages/AccountsPage";
-import { DashboardPage } from "./pages/DashboardPage";
-import { GapsPage } from "./pages/GapsPage";
-import { JobsPage } from "./pages/JobsPage";
-import { MissionsPage } from "./pages/MissionsPage";
-import { PatrolPage } from "./pages/PatrolPage";
-import { PersonasPage } from "./pages/PersonasPage";
-import { PublishPage } from "./pages/PublishPage";
-import { RegisterMemberPage } from "./pages/RegisterMemberPage";
-import { SettingsPage } from "./pages/SettingsPage";
-import { BrandsPage } from "./pages/BrandsPage";
-import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth";
-import { persistActiveThreadsAccountId } from "./lib/activeAccount";
-
-const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps", "register"]);
-const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]);
-
-export function App() {
- const { member, loading } = useAuth();
- const [page, setPage] = useState(() => normalizePage(location.hash.replace("#", "")));
- const [accountVersion, setAccountVersion] = useState(0);
- const [accountCount, setAccountCount] = useState(null);
-
- useEffect(() => {
- const onHash = () => setPage(normalizePage(location.hash.replace("#", "")));
- window.addEventListener("hashchange", onHash);
- return () => window.removeEventListener("hashchange", onHash);
- }, []);
-
- useEffect(() => {
- const onAccountChanged = () => setAccountVersion((value) => value + 1);
- window.addEventListener("haixun:active-account-changed", onAccountChanged);
- return () => window.removeEventListener("haixun:active-account-changed", onAccountChanged);
- }, []);
-
- useEffect(() => {
- if (!member) return;
- const result = parseOAuthReturn(window.location.search);
- if (!result) return;
-
- clearOAuthPending();
- stashOAuthReturnMessage(result.message);
- if (result.accountId) persistActiveThreadsAccountId(result.accountId);
- stripOAuthReturnFromUrl();
- window.location.hash = "accounts";
- setPage("accounts");
- setAccountVersion((value) => value + 1);
- window.dispatchEvent(new CustomEvent("haixun:active-account-changed"));
- }, [member]);
-
- useEffect(() => {
- if (!member) {
- setAccountCount(null);
- return;
- }
- let alive = true;
- async function loadAccounts() {
- try {
- const data = await api.threadsAccounts();
- if (alive) setAccountCount(data.list?.length || 0);
- } catch {
- if (alive) setAccountCount(0);
- }
- }
- void loadAccounts();
- return () => {
- alive = false;
- };
- }, [member, accountVersion]);
-
- function navigate(next: string) {
- const normalized = normalizePage(next);
- window.location.hash = normalized;
- setPage(normalized);
- }
-
- if (loading) {
- return ;
- }
-
- if (!member) {
- return ;
- }
-
- if (needsNativeEmailVerification(member)) {
- return ;
- }
-
- const effectivePage = page === "register" && !member.roles?.includes("admin") ? "dashboard" : page;
-
- return (
-
- {renderPage(effectivePage, navigate, accountCount)}
-
- );
-}
-
-function normalizePage(raw: string) {
- return validPages.has(raw) ? raw : "dashboard";
-}
-
-function renderPage(page: string, navigate: (key: string) => void, accountCount: number | null) {
- if (accountRequiredPages.has(page)) {
- if (accountCount === null) {
- return (
-
- 正在確認 Threads 帳號狀態...
-
- );
- }
- if (accountCount === 0) {
- return ;
- }
- }
- switch (page) {
- case "accounts":
- return ;
- case "brands":
- return ;
- case "publish":
- return ;
- case "personas":
- return ;
- case "missions":
- return ;
- case "patrol":
- return ;
- case "jobs":
- return ;
- case "settings":
- return ;
- case "gaps":
- return ;
- case "register":
- return ;
- default:
- return ;
- }
-}
-
-function AccountRequiredPage({ navigate }: { navigate: (key: string) => void }) {
- return (
-
-
-
-
- 請先到 Threads 帳號頁建立帳號,完成 OAuth 或 API 連線後,巡樓 Console 才能開始發文、分析與跑任務。
-
- navigate("accounts")}>去建立並連線 Threads
-
-
- );
-}
diff --git a/old/backend/web/src/api/client.ts b/old/backend/web/src/api/client.ts
deleted file mode 100644
index e80f3de..0000000
--- a/old/backend/web/src/api/client.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import { safeGetItem, safeRemoveItem, safeSetItem } from "../lib/safeStorage";
-
-export type ApiEnvelope = {
- code: number;
- message: string;
- data?: T;
- error?: {
- biz_code?: string;
- scope?: number;
- category?: number;
- detail?: number;
- };
-};
-
-export class ApiError extends Error {
- status: number;
- code?: number;
- bizCode?: string;
-
- constructor(message: string, status: number, code?: number, bizCode?: string) {
- super(message);
- this.name = "ApiError";
- this.status = status;
- this.code = code;
- this.bizCode = bizCode;
- }
-}
-
-const accessTokenKey = "haixun.access_token";
-const refreshTokenKey = "haixun.refresh_token";
-
-let onUnauthorized: (() => void) | undefined;
-let refreshPromise: Promise | undefined;
-
-export function setUnauthorizedHandler(handler: (() => void) | undefined) {
- onUnauthorized = handler;
-}
-
-export function getAccessToken() {
- return safeGetItem(accessTokenKey);
-}
-
-export function getRefreshToken() {
- return safeGetItem(refreshTokenKey);
-}
-
-export function setTokens(accessToken: string, refreshToken: string) {
- safeSetItem(accessTokenKey, accessToken);
- safeSetItem(refreshTokenKey, refreshToken);
-}
-
-export function clearTokens() {
- safeRemoveItem(accessTokenKey);
- safeRemoveItem(refreshTokenKey);
-}
-
-type RequestOptions = {
- method?: string;
- body?: unknown;
- auth?: boolean;
- memberAuth?: boolean;
- providerToken?: string;
- headers?: Record;
- retryOnUnauthorized?: boolean;
- timeoutMs?: number;
-};
-
-const defaultRequestTimeoutMs = 30_000;
-
-function isTransientGatewayError(status: number) {
- return status === 502 || status === 503 || status === 504;
-}
-
-function gatewayRetryDelayMs(attempt: number) {
- return 400 + attempt * 600;
-}
-
-async function sleep(ms: number) {
- await new Promise((resolve) => window.setTimeout(resolve, ms));
-}
-
-async function readApiEnvelope(response: Response): Promise | null> {
- return (await response.json().catch(() => null)) as ApiEnvelope | null;
-}
-
-function isProxyUnavailable(response: Response, envelope: ApiEnvelope | null) {
- if (!isTransientGatewayError(response.status)) {
- return false;
- }
- const contentType = response.headers.get("content-type") || "";
- if (!contentType.includes("application/json")) {
- return true;
- }
- return envelope == null;
-}
-
-export async function apiRequest(path: string, options: RequestOptions = {}): Promise {
- const maxAttempts = 4;
- let response: Response | null = null;
- let envelope: ApiEnvelope | null = null;
-
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
- try {
- response = await fetchWithTimeout(path, buildRequestInit(options), options.timeoutMs ?? defaultRequestTimeoutMs);
- } catch (err) {
- if (err instanceof ApiError) {
- throw err;
- }
- if (err instanceof DOMException && err.name === "AbortError") {
- throw new ApiError("後端服務回應逾時,請確認 API gateway 是否已啟動。", 504);
- }
- if (err instanceof TypeError) {
- throw err;
- }
- throw new ApiError("後端服務暫時無法連線,請稍後再試。", 502);
- }
- envelope = await readApiEnvelope(response);
- if (!isProxyUnavailable(response, envelope) || attempt === maxAttempts - 1) {
- break;
- }
- await sleep(gatewayRetryDelayMs(attempt));
- }
-
- if (!response) {
- throw new ApiError("後端服務暫時無法連線,請稍後再試", 502);
- }
-
- if (response.status === 401 && options.auth && options.retryOnUnauthorized !== false) {
- const refreshed = await refreshAccessToken();
- if (refreshed) {
- return apiRequest(path, { ...options, retryOnUnauthorized: false });
- }
- onUnauthorized?.();
- }
-
- if (!response.ok || !envelope || envelope.code !== 102000) {
- const message = envelope?.message?.trim();
- const fallback = isProxyUnavailable(response, envelope)
- ? "後端服務暫時無法連線,請確認 API gateway 與反向代理狀態。"
- : `API request failed (${response.status})`;
- const detail =
- message ||
- (response.status === 502
- ? isProxyUnavailable(response, envelope)
- ? "請求逾時或閘道無回應(HTTP 502)。請稍後重試;若持續失敗請確認 API gateway 與 worker 狀態。"
- : "Threads 或外部 API 回傳失敗(HTTP 502)。請到帳號頁確認 Threads API 連線與 threads_keyword_search 權限。"
- : fallback);
- throw new ApiError(detail, response.status, envelope?.code, envelope?.error?.biz_code);
- }
- return envelope.data as T;
-}
-
-async function fetchWithTimeout(path: string, init: RequestInit, timeoutMs: number) {
- const controller = new AbortController();
- const timer = window.setTimeout(() => controller.abort(), timeoutMs);
- try {
- return await fetch(path, { ...init, signal: controller.signal });
- } finally {
- window.clearTimeout(timer);
- }
-}
-
-function buildRequestInit(options: RequestOptions): RequestInit {
- const headers: Record = {
- Accept: "application/json",
- ...options.headers
- };
- const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;
- if (options.body !== undefined && !isFormData) {
- headers["Content-Type"] = "application/json";
- }
- if (options.auth) {
- const token = getAccessToken();
- if (token) {
- headers.Authorization = `Bearer ${token}`;
- }
- }
- if (options.memberAuth) {
- const token = getAccessToken();
- if (token) {
- headers["X-Member-Authorization"] = `Bearer ${token}`;
- }
- }
- if (options.providerToken) {
- headers.Authorization = `Bearer ${options.providerToken}`;
- }
- return {
- method: options.method || (options.body === undefined ? "GET" : "POST"),
- headers,
- body: options.body === undefined ? undefined : isFormData ? (options.body as BodyInit) : JSON.stringify(options.body)
- };
-}
-
-async function refreshAccessToken(): Promise {
- if (refreshPromise) {
- return refreshPromise;
- }
-
- refreshPromise = doRefreshAccessToken().finally(() => {
- refreshPromise = undefined;
- });
- return refreshPromise;
-}
-
-async function doRefreshAccessToken(): Promise {
- const refreshToken = getRefreshToken();
- if (!refreshToken) {
- clearTokens();
- return false;
- }
- try {
- const data = await apiRequest<{ access_token: string; refresh_token: string }>("/api/v1/auth/refresh", {
- method: "POST",
- body: { refresh_token: refreshToken },
- retryOnUnauthorized: false
- });
- setTokens(data.access_token, data.refresh_token);
- return true;
- } catch {
- clearTokens();
- return false;
- }
-}
diff --git a/old/backend/web/src/api/haixun.ts b/old/backend/web/src/api/haixun.ts
deleted file mode 100644
index bfaabe2..0000000
--- a/old/backend/web/src/api/haixun.ts
+++ /dev/null
@@ -1,1246 +0,0 @@
-import { apiRequest } from "./client";
-
-export type Pagination = {
- total: number;
- page: number;
- pageSize: number;
- totalPages: number;
-};
-
-export type CapabilityData = {
- discover_ready: boolean;
- ai_ready: boolean;
- publish_ready: boolean;
- discover_hint?: string;
- ai_hint?: string;
- publish_hint?: string;
- active_threads_account_id?: string;
-};
-
-export type MemberData = {
- tenant_id: string;
- uid: string;
- email: string;
- email_verified: boolean;
- display_name?: string;
- avatar?: string;
- phone?: string;
- language?: string;
- currency?: string;
- status: string;
- origin: string;
- roles?: string[];
- business_email?: string;
- business_email_verified: boolean;
- business_phone?: string;
- business_phone_verified: boolean;
- create_at: number;
- update_at: number;
-};
-
-export type MemberListData = {
- pagination: Pagination;
- list: MemberData[];
-};
-
-export type ThreadsAccount = {
- id: string;
- display_name?: string;
- username?: string;
- threads_user_id?: string;
- persona_id?: string;
- browser_connected: boolean;
- api_connected: boolean;
- api_token_expires_at?: number;
- status: string;
-};
-
-export type ThreadsAccountList = {
- list: ThreadsAccount[];
- active_account_id?: string;
-};
-
-export type DeleteThreadsAccountResult = {
- deleted_id: string;
- active_account_id?: string;
- message: string;
-};
-
-export type ThreadsConnection = {
- account_id: string;
- account_name: string;
- username?: string;
- browser_connected: boolean;
- api_connected: boolean;
- prefs: Record;
-};
-
-export type AiSettings = {
- account_id?: string;
- provider: string;
- model: string;
- research_provider?: string;
- research_model?: string;
- api_keys?: Record;
- api_keys_configured?: Record;
-};
-
-export type ThreadsAccountAiProviderModels = {
- id: string;
- label: string;
- models: string[];
- streams: boolean;
- error?: string;
-};
-
-export type PublishQueueItem = {
- id: string;
- account_id: string;
- persona_id?: string;
- copy_mission_id?: string;
- copy_draft_id?: string;
- text: string;
- topic_tag?: string;
- scheduled_at: number;
- status: string;
- media_id?: string;
- permalink?: string;
- published_at?: number;
- error_message?: string;
- retry_count?: number;
- last_attempt_at?: number;
- next_attempt_at?: number;
- missed_at?: number;
- paused_reason?: string;
- create_at: number;
- update_at: number;
-};
-
-export type PublishQueueList = {
- pagination: Pagination;
- list: PublishQueueItem[];
-};
-
-export type PublishHealth = {
- summary: {
- pending_scheduled: number;
- failed_count: number;
- published_7d: number;
- total_likes_7d: number;
- total_replies_7d: number;
- };
- pagination: Pagination;
- list: Array<{
- media_id: string;
- permalink?: string;
- text?: string;
- published_at: number;
- like_count: number;
- reply_count: number;
- repost_count?: number;
- quote_count?: number;
- views?: number;
- shares?: number;
- checkpoints?: Array<{ checkpoint: string; like_count: number; reply_count: number; checked_at: number }>;
- }>;
-};
-
-export type OwnPostPerformanceItem = {
- media_id: string;
- text?: string;
- permalink?: string;
- timestamp?: string;
- media_type?: string;
- media_url?: string;
- thumbnail_url?: string;
- topic_tag?: string;
- shortcode?: string;
- like_count: number;
- reply_count: number;
- repost_count?: number;
- quote_count?: number;
- views?: number;
- shares?: number;
- insights_status?: string;
- insights_message?: string;
-};
-
-export type PostPerformance = {
- account_id: string;
- username?: string;
- threads_user_id?: string;
- fetched_at: number;
- account_insights?: {
- status?: string;
- message?: string;
- metrics?: Array<{ name: string; value: number }>;
- };
- posts: OwnPostPerformanceItem[];
-};
-
-export type OwnPostFormulaReview = {
- media_id?: string;
- reviewed_at?: number;
- from_cache?: boolean;
- summary: string;
- wins?: string[];
- improvements?: string[];
- formula?: string;
- post_template?: string;
- hook_pattern?: string;
- structure?: string;
- replication_tips?: string[];
- avoid?: string[];
-};
-
-export type Persona = {
- id: string;
- display_name?: string;
- persona?: string;
- brief?: string;
- style_profile?: string;
- style_benchmark?: string;
- create_at: number;
- update_at: number;
-};
-
-export type CopyDraft = {
- id: string;
- persona_id: string;
- content_plan_id?: string;
- copy_mission_id?: string;
- scan_post_id?: string;
- formula_id?: string;
- draft_type: string;
- text: string;
- topic_tag?: string;
- angle?: string;
- hook?: string;
- rationale?: string;
- ai_score?: number;
- formula_score?: number;
- brand_fit_score?: number;
- risk_score?: number;
- similarity_score?: number;
- engagement_potential?: number;
- freshness_score?: number;
- review_suggestion?: string;
- status?: string;
- publish_queue_id?: string;
- published_at?: number;
- published_permalink?: string;
- create_at?: number;
-};
-
-export type CopyMissionInspiration = {
- topic_candidate_id?: string;
- content_plan_id?: string;
- label: string;
- seed_query: string;
- brief: string;
- trend_reason?: string;
- trend_keywords?: string[];
- angles?: string[];
- mission?: string;
- target_audience?: string;
- opening_type?: string;
- body_type?: string;
- emotion?: string;
- cta_type?: string;
- risk_level?: string;
- avoid?: string[];
- sources?: Array<{ query?: string; title?: string; snippet?: string; url?: string }>;
- web_search_used: boolean;
- message: string;
-};
-
-export type TopicCandidate = {
- id: string;
- persona_id: string;
- name: string;
- source?: string;
- category?: string;
- seed_query?: string;
- trend_reason?: string;
- trend_keywords?: string[];
- heat_score?: number;
- fit_score?: number;
- interaction_score?: number;
- extend_score?: number;
- risk_score?: number;
- final_score?: number;
- recommended_missions?: string[];
- status?: string;
- create_at: number;
- update_at: number;
-};
-
-export type ContentPlan = {
- id: string;
- persona_id: string;
- topic_candidate_id?: string;
- topic: string;
- mission: string;
- target_audience?: string;
- angle?: string;
- opening_type?: string;
- body_type?: string;
- emotion?: string;
- ending_type?: string;
- cta_type?: string;
- risk_level?: string;
- requires_human_review?: boolean;
- avoid?: string[];
- selected_knowledge?: string[];
- status?: string;
- create_at: number;
- update_at: number;
-};
-
-export type FeedbackEvent = {
- id: string;
- persona_id: string;
- content_plan_id?: string;
- draft_id?: string;
- decision: string;
- note?: string;
- snapshot?: string;
- create_at: number;
-};
-
-export type KnowledgeSource = { id: string; persona_id: string; source_type: string; title?: string; filename?: string; parsed_status?: string; chunk_count?: number; create_at: number };
-export type KnowledgeChunk = { id: string; persona_id: string; source_id: string; content: string; topics?: string[]; style_tags?: string[]; risk_level?: string; create_at: number };
-export type FormulaPool = { id: string; persona_id: string; type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number; create_at: number; update_at: number };
-
-export type ContentInboxItem = CopyDraft & {
- scheduled_at?: number;
- lifecycle: "draft" | "scheduled" | "published" | string;
- group_date: number;
- formula_label?: string;
-};
-
-export type ContentFormula = {
- id: string;
- account_id: string;
- label: string;
- source_type: string;
- source_ref?: string;
- source_post_text?: string;
- source_author?: string;
- source_permalink?: string;
- summary?: string;
- wins?: string[];
- improvements?: string[];
- formula: string;
- post_template?: string;
- hook_pattern?: string;
- structure?: string;
- replication_tips?: string[];
- avoid?: string[];
- tags?: string[];
- create_at: number;
- update_at: number;
-};
-
-export type ViralScanPost = {
- id: string;
- search_tag: string;
- permalink: string;
- author: string;
- text: string;
- like_count: number;
- reply_count: number;
- engagement_score: number;
- source: string;
- create_at: number;
-};
-
-export type ContentFormulaSearchPost = {
- text: string;
- author: string;
- permalink?: string;
- media_id?: string;
- like_count: number;
- reply_count: number;
- engagement_score: number;
-};
-
-export type PublishSlot = {
- weekday: number;
- time: string;
-};
-
-export type PublishSourceRef = {
- type: string;
- persona_id?: string;
- copy_mission_id?: string;
- scan_post_id?: string;
- manual_seed?: string;
-};
-
-export type PublishInventoryPolicy = {
- account_id: string;
- enabled: boolean;
- target_daily_count: number;
- low_stock_threshold: number;
- lookahead_days: number;
- timezone: string;
- slots: PublishSlot[];
- source_refs: PublishSourceRef[];
- update_at: number;
-};
-
-export type PublishGuardPolicy = {
- account_id: string;
- enabled: boolean;
- max_daily_posts: number;
- min_interval_minutes: number;
- consecutive_failure_pause_limit: number;
- paused: boolean;
- paused_reason?: string;
- update_at: number;
-};
-
-export type PublishSlotInsights = {
- account_id: string;
- timezone: string;
- slots: Array;
-};
-
-export type PublishQueueEvent = {
- id: string;
- queue_id: string;
- event_type: string;
- from_status?: string;
- to_status?: string;
- message?: string;
- create_at: number;
-};
-
-export type PublishAlert = {
- type: string;
- severity: string;
- message: string;
- queue_id?: string;
- account_id: string;
- create_at: number;
-};
-
-export type PublishDashboardSummary = {
- list: Array<{
- account_id: string;
- account_name: string;
- pending_scheduled: number;
- failed_count: number;
- published_7d: number;
- total_likes_7d: number;
- total_replies_7d: number;
- paused: boolean;
- best_slot?: string;
- low_slot?: string;
- }>;
- total_pending: number;
- total_failed: number;
- total_published_7d: number;
- total_likes_7d: number;
- total_replies_7d: number;
-};
-
-export type ThreadsDiagnostics = {
- account_id: string;
- checked_at: number;
- api_connected: boolean;
- token_expires_at?: number;
- scopes: string[];
- items: Array<{ scope: string; name: string; status: string; message: string; count: number }>;
- message: string;
-};
-
-export type ThreadsPlaygroundResult = {
- action: string;
- ok: boolean;
- message: string;
- data: string;
-};
-
-export type MentionInboxItem = {
- media_id: string;
- author_username?: string;
- text?: string;
- permalink?: string;
- shortcode?: string;
- timestamp?: string;
- media_type?: string;
- is_reply: boolean;
- is_quote_post: boolean;
- has_replies: boolean;
- root_post_id?: string;
- parent_id?: string;
- thread_post_text?: string;
- parent_reply_text?: string;
- reply_ready: boolean;
- reply_ready_msg?: string;
- synced_at: number;
-};
-
-export type SyncMentionInboxResult = {
- synced: number;
- ready: number;
- total: number;
-};
-
-export type MentionInboxList = {
- list: MentionInboxItem[];
- pagination: Pagination;
- ready_total?: number;
- newest_synced_at?: number;
-};
-
-export type ThreadsReply = {
- id: string;
- text?: string;
- username?: string;
- permalink?: string;
- timestamp?: string;
- like_count?: number;
- parent_id?: string;
- replied_to_id?: string;
- root_post_id?: string;
- is_reply?: boolean;
- is_reply_owned_by_me?: boolean;
- has_replies?: boolean;
- media_type?: string;
- media_url?: string;
- thumbnail_url?: string;
- gif_url?: string;
- media_children?: Array<{
- id?: string;
- media_type?: string;
- media_url?: string;
- thumbnail_url?: string;
- }>;
-};
-
-export type ThreadsInspirationPost = {
- id: string;
- text?: string;
- username?: string;
- permalink?: string;
- timestamp?: string;
- like_count?: number;
- reply_count?: number;
-};
-
-export type StylePreset = {
- id: string;
- persona_id: string;
- name: string;
- tone?: string;
- cta?: string[];
- banned_words?: string[];
- notes?: string;
- create_at: number;
- update_at: number;
-};
-
-// ==================== Brand (full contract per backend) ====================
-export type ResearchItemData = {
- title?: string;
- url?: string;
- snippet?: string;
- query?: string;
-};
-
-export type ResearchMapData = {
- audience_summary?: string;
- content_goal?: string;
- questions?: string[];
- pillars?: string[];
- exclusions?: string[];
- research_items?: ResearchItemData[];
- expand_strategy?: string;
- patrol_keywords?: string[];
-};
-
-export type BrandProductData = {
- id: string;
- label: string;
- product_context: string;
- match_tags?: string[];
- placement_url?: string; // 置入網址
- create_at: number;
- update_at: number;
-};
-
-export type BrandData = {
- id: string;
- display_name?: string;
- topic_name?: string;
- seed_query?: string;
- brief?: string;
- product_brief?: string;
- product_context?: string;
- product_id?: string;
- products?: BrandProductData[];
- target_audience?: string;
- goals?: string;
- research_map?: ResearchMapData;
- create_at: number;
- update_at: number;
-};
-
-export type ListBrandsData = { list: BrandData[] };
-
-export type PlacementTopicData = {
- id: string;
- brand_id: string;
- brand_display_name?: string;
- topic_name?: string;
- seed_query?: string;
- brief?: string;
- product_id?: string;
- research_map?: ResearchMapData;
- create_at: number;
- update_at: number;
-};
-
-export type ListPlacementTopicsData = { list: PlacementTopicData[] };
-
-export type KnowledgeGraphEvidenceData = {
- url?: string;
- snippet?: string;
- query?: string;
-};
-
-export type BraveSourceData = {
- query?: string;
- title?: string;
- url?: string;
- snippet?: string;
-};
-
-export type KnowledgeGraphNodeData = {
- id: string;
- label: string;
- node_kind: string;
- type: string;
- layer: number;
- relation?: string;
- placement_value?: string;
- product_fit_score: number;
- selected_for_scan: boolean;
- relevance_tags: string[];
- recency_tags: string[];
- evidence?: KnowledgeGraphEvidenceData[];
-};
-
-export type KnowledgeGraphEdgeData = {
- from: string;
- to: string;
- relation: string;
-};
-
-export type KnowledgeGraphData = {
- id: string;
- brand_id: string;
- seed: string;
- nodes: KnowledgeGraphNodeData[];
- edges: KnowledgeGraphEdgeData[];
- brave_sources?: BraveSourceData[];
- expand_strategy?: string;
- pain_tag_count: number;
- generated_at: number;
- create_at: number;
- update_at: number;
-};
-
-export type ExpandKnowledgeGraphData = {
- job_id: string;
- status: string;
- message: string;
-};
-
-export type ScanReplyData = {
- external_id?: string;
- author?: string;
- text: string;
- permalink?: string;
- like_count?: number;
- posted_at?: string;
-};
-
-export type GenerateOutreachDraftsData = {
- id: string;
- scan_post_id: string;
- relevance: number;
- reason: string;
- drafts: Array<{ text: string; angle: string; rationale: string }>;
- create_at: number;
-};
-
-export type PublishOutreachDraftData = {
- scan_post_id: string;
- reply_id?: string;
- permalink?: string;
- outreach_status: string;
- published_permalink?: string;
- message: string;
-};
-
-export type ScanPostData = {
- id: string;
- graph_node_id: string;
- search_tag: string;
- query_dimension: string;
- recency_days?: number;
- external_id: string;
- permalink?: string;
- author: string;
- text: string;
- priority: string;
- placement_score: number;
- product_fit_score: number;
- solved_by_product: boolean;
- source: string;
- scan_job_id: string;
- outreach_status?: string;
- published_reply_id?: string;
- published_permalink?: string;
- outreach_update_at?: number;
- posted_at?: string;
- replies?: ScanReplyData[];
- latest_draft?: GenerateOutreachDraftsData;
- create_at: number;
-};
-
-export type ListBrandScanPostsData = {
- list: ScanPostData[];
- total: number;
-};
-
-export type ContentMatrixRowData = {
- sort_order: number;
- search_tag: string;
- angle: string;
- hook: string;
- text: string;
- reference_notes: string;
- source_permalinks: string[];
- rationale: string;
-};
-
-export type ContentMatrixData = {
- id?: string;
- brand_id: string;
- rows: ContentMatrixRowData[];
- generated_at: number;
- create_at?: number;
- update_at?: number;
-};
-
-export type BrandScanScheduleData = {
- id?: string;
- brand_id: string;
- cron: string;
- timezone: string;
- enabled: boolean;
- next_run_at?: number;
- last_run_at?: number;
-};
-
-// Legacy aliases for existing PatrolPage usage (keep minimal surface)
-export type Brand = {
- id: string;
- display_name?: string;
- topic_name?: string;
- seed_query?: string;
- brief?: string;
- product_context?: string;
- target_audience?: string;
- goals?: string;
-};
-
-export type ScanPost = {
- id: string;
- author: string;
- text: string;
- permalink?: string;
- priority?: string;
- placement_score?: number;
- product_fit_score?: number;
- outreach_status?: string;
-};
-
-export type JobStepProgress = {
- id: string;
- status: string;
- message?: string;
- started_at?: number;
- ended_at?: number;
-};
-
-export type JobRun = {
- id: string;
- template_type: string;
- status: string;
- phase?: string;
- worker_type?: string;
- progress?: { percentage?: number; summary?: string; steps?: JobStepProgress[] };
- error?: string;
- attempt?: number;
- max_attempts?: number;
- create_at: number;
- update_at: number;
-};
-
-export type JobEvent = {
- id: string;
- job_id: string;
- type: string;
- from?: string;
- to?: string;
- message: string;
- metadata?: Record;
- create_at: number;
-};
-
-export type JobList = {
- pagination: Pagination;
- list: JobRun[];
-};
-
-export type JobSchedule = {
- id: string;
- template_type: string;
- cron: string;
- timezone: string;
- enabled: boolean;
- next_run_at?: number;
- last_run_at?: number;
-};
-
-export type SettingValue = {
- scope: string;
- scope_id: string;
- key: string;
- value: unknown;
- version?: number;
-};
-
-export const api = {
- capabilities: () => apiRequest("/api/v1/members/me/capabilities", { auth: true }),
- memberMe: () => apiRequest("/api/v1/members/me", { auth: true }),
- verifyEmail: (code: string) =>
- apiRequest<{ email_verified: boolean }>("/api/v1/auth/verify-email", { method: "POST", body: { code }, auth: true }),
- resendVerificationEmail: () =>
- apiRequest<{ sent: boolean }>("/api/v1/auth/resend-verification-email", { method: "POST", auth: true }),
- updateMemberMe: (body: Record) => apiRequest("/api/v1/members/me", { method: "PATCH", body, auth: true }),
- uploadMemberAvatar: (file: File) => {
- const form = new FormData();
- form.append("avatar", file);
- return apiRequest<{ avatar: string }>("/api/v1/members/me/avatar", { method: "POST", body: form, auth: true });
- },
- members: (page = 1, pageSize = 100) => apiRequest(`/api/v1/members?page=${page}&pageSize=${pageSize}`, { auth: true }),
- updateMemberProfile: (uid: string, body: Record) =>
- apiRequest(`/api/v1/members/${encodeURIComponent(uid)}/profile`, { method: "PATCH", body, auth: true }),
- updateMemberRoles: (uid: string, roles: string[]) =>
- apiRequest(`/api/v1/members/${encodeURIComponent(uid)}/roles`, { method: "PATCH", body: { roles }, auth: true }),
- updateMemberPassword: (uid: string, password: string) =>
- apiRequest(`/api/v1/members/${encodeURIComponent(uid)}/password`, { method: "PATCH", body: { password }, auth: true }),
- placementSettings: () => apiRequest("/api/v1/members/me/placement-settings", { auth: true }),
- updatePlacementSettings: (body: Record) =>
- apiRequest("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }),
- memberAiSettings: () => apiRequest("/api/v1/members/me/ai-settings", { auth: true }),
- updateMemberAiSettings: (body: Record) =>
- apiRequest("/api/v1/members/me/ai-settings", { method: "PUT", body, auth: true }),
- memberAiProviderModels: (provider: string, apiKey?: string) =>
- apiRequest(`/api/v1/members/me/ai-settings/providers/${provider}/models`, {
- method: "POST",
- body: apiKey ? { api_key: apiKey } : {},
- auth: true
- }),
-
- threadsAccounts: () => apiRequest("/api/v1/threads-accounts/", { auth: true }),
- createThreadsAccount: (body: { display_name?: string; activate?: boolean }) =>
- apiRequest("/api/v1/threads-accounts/", { method: "POST", body, auth: true }),
- deleteThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}`, { method: "DELETE", auth: true }),
- activateThreadsAccount: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/activate`, { method: "POST", auth: true }),
- getThreadsConnection: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/connection`, { auth: true }),
- updateThreadsConnection: (id: string, body: Record) =>
- apiRequest(`/api/v1/threads-accounts/${id}/connection`, { method: "PATCH", body, auth: true }),
- importThreadsAccountSession: (id: string, storageState: string) =>
- apiRequest<{ success: boolean; valid: boolean; synced: boolean; message: string; account_id: string; username?: string }>(
- `/api/v1/threads-accounts/${id}/session/import`,
- { method: "POST", body: { storageState }, auth: true }
- ),
- threadsAiSettings: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }),
- updateThreadsAiSettings: (id: string, body: Record) =>
- apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }),
- threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) =>
- apiRequest(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, {
- method: "POST",
- body: apiKey ? { api_key: apiKey } : {},
- auth: true
- }),
- oauthConfig: () => apiRequest<{ enabled: boolean; redirect_uri: string; success_redirect: string; requires_https: boolean }>("/api/v1/threads-accounts/oauth/config", { auth: true }),
- oauthLogs: () => apiRequest<{ list: Array<{ at: number; level: string; stage: string; message: string }> }>("/api/v1/threads-accounts/oauth/logs", { auth: true }),
- startOauth: (accountId?: string) =>
- apiRequest<{ authorize_url: string; account_id?: string; state?: string }>(
- `/api/v1/threads-accounts/oauth/start${accountId ? `?account_id=${encodeURIComponent(accountId)}` : ""}`,
- { auth: true }
- ),
- smokeTest: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/api/smoke-test`, { method: "POST", auth: true }),
- threadsDiagnostics: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/diagnostics`, { auth: true }),
- publishDashboardSummary: () => apiRequest("/api/v1/threads-accounts/publish-dashboard-summary", { auth: true }),
- threadsPlayground: (id: string, body: Record) =>
- apiRequest(`/api/v1/threads-accounts/${id}/api/playground`, { method: "POST", body, auth: true }),
- syncMentionInbox: (accountId: string, limit = 25, maxPages = 4) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/mention-inbox/sync`, {
- method: "POST",
- body: { limit, max_pages: maxPages },
- auth: true
- }),
- listMentionInbox: (accountId: string, page = 1, pageSize = 8) =>
- apiRequest(
- `/api/v1/threads-accounts/${accountId}/mention-inbox?page=${page}&pageSize=${pageSize}`,
- { auth: true }
- ),
- uploadPublishAttachment: (accountId: string, file: File) => {
- const form = new FormData();
- form.append("image", file);
- return apiRequest<{ attachment_key: string; public_url: string }>(
- `/api/v1/threads-accounts/${accountId}/publish-attachments`,
- { method: "POST", body: form, auth: true }
- );
- },
- publishMentionReply: (accountId: string, mediaId: string, body: { text?: string; image?: File }) => {
- const form = new FormData();
- if (body.text?.trim()) form.append("text", body.text.trim());
- if (body.image) form.append("image", body.image);
- return apiRequest<{ media_id?: string; permalink?: string }>(
- `/api/v1/threads-accounts/${accountId}/mention-inbox/${encodeURIComponent(mediaId)}/reply`,
- { method: "POST", body: form, auth: true, timeoutMs: 120_000 }
- );
- },
- publishThreadsReply: (accountId: string, body: { reply_to_id: string; text?: string; image?: File }) => {
- const form = new FormData();
- form.append("reply_to_id", body.reply_to_id);
- if (body.text?.trim()) form.append("text", body.text.trim());
- if (body.image) form.append("image", body.image);
- return apiRequest<{ media_id?: string; permalink?: string }>(
- `/api/v1/threads-accounts/${accountId}/publish-reply`,
- { method: "POST", body: form, auth: true, timeoutMs: 120_000 }
- );
- },
-
- publishQueue: (accountId: string, status = "", page = 1) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue?page=${page}&pageSize=20${status ? `&status=${status}` : ""}`, { auth: true }),
- createPublishQueue: (accountId: string, body: { text?: string; image_key?: string; image_keys?: string[]; topic_tag?: string; scheduled_at?: number }) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue`, { method: "POST", body, auth: true }),
- patchPublishQueue: (accountId: string, qid: string, body: { text?: string; topic_tag?: string; scheduled_at?: number }) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "PATCH", body, auth: true }),
- cancelPublishQueue: (accountId: string, qid: string) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/cancel`, { method: "POST", auth: true }),
- deletePublishQueue: (accountId: string, qid: string) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}`, { method: "DELETE", auth: true }),
- retryPublishQueue: (accountId: string, qid: string) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/retry`, { method: "POST", auth: true }),
- publishQueueEvents: (accountId: string, qid: string) =>
- apiRequest<{ list: PublishQueueEvent[] }>(`/api/v1/threads-accounts/${accountId}/publish-queue/${qid}/events`, { auth: true }),
- publishAlerts: (accountId: string) => apiRequest<{ list: PublishAlert[] }>(`/api/v1/threads-accounts/${accountId}/publish-alerts`, { auth: true }),
- publishCalendar: (accountId: string, startAt: number, endAt: number, status = "") =>
- apiRequest(
- `/api/v1/threads-accounts/${accountId}/publish-calendar?startAt=${startAt}&endAt=${endAt}${status ? `&status=${status}` : ""}`,
- { auth: true }
- ),
- publishInventoryPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { auth: true }),
- updatePublishInventoryPolicy: (accountId: string, body: Partial) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-inventory-policy`, { method: "PUT", body, auth: true }),
- startPublishInventoryRefill: (accountId: string, count?: number) =>
- apiRequest<{ job_id: string; status: string; message: string }>(`/api/v1/threads-accounts/${accountId}/publish-inventory/refill-jobs`, { method: "POST", body: { count }, auth: true }),
- publishSlotInsights: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-slot-insights`, { auth: true }),
- publishGuardPolicy: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { auth: true }),
- updatePublishGuardPolicy: (accountId: string, body: Partial) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard-policy`, { method: "PUT", body, auth: true }),
- resumePublishGuard: (accountId: string) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/publish-guard/resume`, { method: "POST", auth: true }),
- publishHealth: (accountId: string) => apiRequest(`/api/v1/threads-accounts/${accountId}/publish-health?page=1&pageSize=10`, { auth: true }),
- postPerformance: (accountId: string, limit = 30) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/post-performance?limit=${limit}`, { auth: true }),
- generateOwnPostReplyDraft: (
- accountId: string,
- body: {
- media_id: string;
- persona_id?: string;
- reply_to_id?: string;
- post_text?: string;
- reply_text?: string;
- thread_post_text?: string;
- parent_reply_text?: string;
- }
- ) =>
- apiRequest<{ text: string }>(`/api/v1/threads-accounts/${accountId}/own-post-reply-draft`, {
- method: "POST",
- body,
- auth: true
- }),
- generateOwnPostFormula: (
- accountId: string,
- body: {
- media_id: string;
- persona_id?: string;
- post_text?: string;
- topic_tag?: string;
- media_type?: string;
- timestamp?: string;
- like_count?: number;
- reply_count?: number;
- views?: number;
- repost_count?: number;
- quote_count?: number;
- shares?: number;
- force?: boolean;
- }
- ) =>
- apiRequest(`/api/v1/threads-accounts/${accountId}/own-post-formula`, {
- method: "POST",
- body,
- auth: true
- }),
- listOwnPostFormulas: (accountId: string) =>
- apiRequest<{ list: OwnPostFormulaReview[] }>(`/api/v1/threads-accounts/${accountId}/own-post-formulas`, { auth: true }),
-
- personas: () => apiRequest<{ list: Persona[] }>("/api/v1/personas/", { auth: true }),
- createPersona: (body: { display_name?: string }) => apiRequest("/api/v1/personas/", { method: "POST", body, auth: true }),
- updatePersona: (id: string, body: Record) => apiRequest(`/api/v1/personas/${id}`, { method: "PATCH", body, auth: true }),
- deletePersona: (id: string) => apiRequest(`/api/v1/personas/${id}`, { method: "DELETE", auth: true }),
- startStyleAnalysis: (id: string, benchmark_username: string) =>
- apiRequest<{ job_id: string; status: string }>(`/api/v1/personas/${id}/style-analysis`, { method: "POST", body: { benchmark_username }, auth: true }),
- startStyleAnalysisFromText: (
- id: string,
- body: { reference_texts?: string[]; raw_text?: string; source_label?: string }
- ) =>
- apiRequest<{ persona: Persona; post_count: number; message: string }>(
- `/api/v1/personas/${id}/style-analysis-from-text`,
- { method: "POST", body, auth: true }
- ),
- viralScanPosts: (personaId: string, limit = 30) =>
- apiRequest<{ list: ViralScanPost[]; total: number }>(`/api/v1/personas/${personaId}/viral-scan-posts?limit=${limit}`, { auth: true }),
- personaDrafts: (id: string) => apiRequest<{ list: CopyDraft[]; total: number }>(`/api/v1/personas/${id}/copy-drafts`, { auth: true }),
- updateDraft: (personaId: string, draftId: string, body: Record) =>
- apiRequest(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "PATCH", body, auth: true }),
- pruneCopyDrafts: (personaId: string, body?: { keep?: number }) =>
- apiRequest<{ deleted: number; kept: number; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/prune`, { method: "POST", body: body ?? {}, auth: true }),
- deleteDraft: (personaId: string, draftId: string) =>
- apiRequest<{ draft_id: string; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}`, { method: "DELETE", auth: true }),
- schedulePersonaDrafts: (personaId: string, body: { account_id: string; draft_ids: string[]; start_at?: number; timezone?: string; slots?: PublishSlot[]; mode?: string }) =>
- apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/schedule`, { method: "POST", body, auth: true }),
- contentInbox: (personaId: string, params?: { page?: number; pageSize?: number; status?: string; rangeStart?: number; rangeEnd?: number }) => {
- const q = new URLSearchParams();
- if (params?.page) q.set("page", String(params.page));
- if (params?.pageSize) q.set("pageSize", String(params.pageSize));
- if (params?.status) q.set("status", params.status);
- if (params?.rangeStart) q.set("rangeStart", String(params.rangeStart));
- if (params?.rangeEnd) q.set("rangeEnd", String(params.rangeEnd));
- const suffix = q.toString() ? `?${q.toString()}` : "";
- return apiRequest<{ pagination: Pagination; list: ContentInboxItem[] }>(`/api/v1/personas/${personaId}/content-inbox${suffix}`, { auth: true });
- },
- scheduleCopyDraft: (personaId: string, draftId: string, body: { account_id: string; topic_tag?: string; scheduled_at?: number }) =>
- apiRequest<{ scheduled: number; list: PublishQueueItem[]; message: string }>(`/api/v1/personas/${personaId}/copy-drafts/${draftId}/schedule`, { method: "POST", body, auth: true }),
- generateFromFormula: (personaId: string, formulaId: string, body: { account_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
- apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/content-formulas/${formulaId}/generate`, { method: "POST", body, auth: true }),
- contentFormulas: (accountId: string, params?: { page?: number; pageSize?: number; source_type?: string; tag?: string }) => {
- const q = new URLSearchParams();
- if (params?.page) q.set("page", String(params.page));
- if (params?.pageSize) q.set("pageSize", String(params.pageSize));
- if (params?.source_type) q.set("source_type", params.source_type);
- if (params?.tag) q.set("tag", params.tag);
- const suffix = q.toString() ? `?${q.toString()}` : "";
- return apiRequest<{ pagination: Pagination; list: ContentFormula[] }>(`/api/v1/threads-accounts/${accountId}/content-formulas${suffix}`, { auth: true });
- },
- analyzeContentFormula: (accountId: string, body: Record) =>
- apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/analyze`, { method: "POST", body, auth: true }),
- searchContentFormulaPosts: (accountId: string, body: { keyword: string; search_type?: string; limit?: number }) =>
- apiRequest<{ list: ContentFormulaSearchPost[]; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/search-posts`, { method: "POST", body, auth: true }),
- importOwnPostToFormula: (accountId: string, body: { media_id: string; label?: string }) =>
- apiRequest<{ formula: ContentFormula; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/import-own-post`, { method: "POST", body, auth: true }),
- deleteContentFormula: (accountId: string, formulaId: string) =>
- apiRequest<{ formula_id: string; message: string }>(`/api/v1/threads-accounts/${accountId}/content-formulas/${formulaId}`, { method: "DELETE", auth: true }),
- stylePresets: (personaId: string) => apiRequest<{ list: StylePreset[] }>(`/api/v1/personas/${personaId}/style-presets`, { auth: true }),
- upsertStylePreset: (personaId: string, presetId: string, body: { name: string; tone?: string; cta?: string[]; banned_words?: string[]; notes?: string; apply?: boolean }) =>
- apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "PUT", body, auth: true }),
- deleteStylePreset: (personaId: string, presetId: string) =>
- apiRequest(`/api/v1/personas/${personaId}/style-presets/${presetId}`, { method: "DELETE", auth: true }),
-
- generateTopicMatrix: (personaId: string, body: { topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
- apiRequest<{ list: CopyDraft[]; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/generate`, { method: "POST", body, auth: true }),
- startTopicMatrixJob: (personaId: string, body: { topic: string; content_plan_id?: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
- apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/topic-matrix/jobs`, { method: "POST", body, auth: true }),
- startFormulaDraftJob: (personaId: string, body: { account_id: string; formula_id: string; topic: string; brief?: string; use_web_search?: boolean; draft_count?: number }) =>
- apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/formula-draft/jobs`, { method: "POST", body, auth: true }),
- startRewriteDraftJob: (personaId: string, body: { account_id: string; reference_text: string; topic: string; brief?: string; save_label?: string; use_web_search?: boolean; draft_count?: number }) =>
- apiRequest<{ job_id: string; status: string; ai_provider: string; ai_model: string; message: string }>(`/api/v1/personas/${personaId}/rewrite-draft/jobs`, { method: "POST", body, auth: true }),
- inspireCopyMission: (personaId: string, body: { keyword?: string; content_direction?: string; use_web_search?: boolean; avoid_topics?: string[] }) =>
- apiRequest(`/api/v1/personas/${personaId}/copy-mission-inspiration`, { method: "POST", body, auth: true }),
- topicCandidates: (personaId: string) =>
- apiRequest<{ list: TopicCandidate[] }>(`/api/v1/personas/${personaId}/topic-candidates`, { auth: true }),
- contentPlans: (personaId: string) =>
- apiRequest<{ list: ContentPlan[] }>(`/api/v1/personas/${personaId}/content-plans`, { auth: true }),
- updateContentPlan: (personaId: string, contentPlanId: string, body: Record) =>
- apiRequest(`/api/v1/personas/${personaId}/content-plans/${contentPlanId}`, { method: "PATCH", body, auth: true }),
- createFeedbackEvent: (personaId: string, body: { content_plan_id?: string; draft_id?: string; decision: string; note?: string; snapshot?: string }) =>
- apiRequest(`/api/v1/personas/${personaId}/feedback-events`, { method: "POST", body, auth: true }),
- createKnowledgeSource: (personaId: string, body: { source_type: string; title?: string; filename?: string; content?: string; content_base64?: string }) =>
- apiRequest(`/api/v1/personas/${personaId}/knowledge-sources`, { method: "POST", body, auth: true }),
- knowledgeSources: (personaId: string) =>
- apiRequest<{ list: KnowledgeSource[] }>(`/api/v1/personas/${personaId}/knowledge-sources`, { auth: true }),
- knowledgeChunks: (personaId: string) =>
- apiRequest<{ list: KnowledgeChunk[] }>(`/api/v1/personas/${personaId}/knowledge-chunks`, { auth: true }),
- formulaPools: (personaId: string) =>
- apiRequest<{ list: FormulaPool[] }>(`/api/v1/personas/${personaId}/formula-pools`, { auth: true }),
- createFormulaPool: (personaId: string, body: { type: string; name: string; pattern?: string; use_cases?: string[]; avoid?: string[]; weight?: number }) =>
- apiRequest(`/api/v1/personas/${personaId}/formula-pools`, { method: "POST", body, auth: true }),
-
- // Brand core
- brands: () => apiRequest("/api/v1/brands/", { auth: true }),
- createBrand: (body: { display_name?: string }) => apiRequest("/api/v1/brands/", { method: "POST", body, auth: true }),
- getBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { auth: true }),
- updateBrand: (id: string, body: Record) => apiRequest(`/api/v1/brands/${id}`, { method: "PATCH", body, auth: true }),
- deleteBrand: (id: string) => apiRequest(`/api/v1/brands/${id}`, { method: "DELETE", auth: true }),
-
- // Brand Products
- listBrandProducts: (id: string) => apiRequest<{ list: BrandProductData[] }>(`/api/v1/brands/${id}/products`, { auth: true }),
- createBrandProduct: (id: string, body: { label: string; product_context: string; match_tags?: string[]; placement_url?: string }) =>
- apiRequest(`/api/v1/brands/${id}/products`, { method: "POST", body, auth: true }),
- updateBrandProduct: (id: string, productId: string, body: Record) =>
- apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "PATCH", body, auth: true }),
- deleteBrandProduct: (id: string, productId: string) =>
- apiRequest(`/api/v1/brands/${id}/products/${productId}`, { method: "DELETE", auth: true }),
-
- // Knowledge Graph
- expandKnowledgeGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) =>
- apiRequest(`/api/v1/brands/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }),
- getKnowledgeGraph: (id: string) => apiRequest(`/api/v1/brands/${id}/knowledge-graph`, { auth: true }),
- patchKnowledgeGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) =>
- apiRequest(`/api/v1/brands/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }),
-
- // Scan jobs & posts
- startBrandScan: (id: string, body: Record) =>
- apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/brands/${id}/scan-jobs`, { method: "POST", body, auth: true }),
- listBrandScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => {
- const qs = new URLSearchParams();
- if (params?.priority) qs.set("priority", params.priority);
- if (params?.recent_7d) qs.set("recent_7d", "true");
- if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min));
- if (params?.limit) qs.set("limit", String(params.limit));
- const q = qs.toString() ? `?${qs.toString()}` : "";
- return apiRequest(`/api/v1/brands/${id}/scan-posts${q}`, { auth: true });
- },
-
- // Outreach
- generateOutreachDrafts: (id: string, body: { scan_post_id: string; topic_id?: string; count?: number; voice_persona_id?: string; product_id?: string }) =>
- apiRequest(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }),
- // keep old simple name for PatrolPage (returns any for now)
- generateOutreachDraftsLegacy: (id: string, body: Record) =>
- apiRequest>(`/api/v1/brands/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }),
- publishOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) =>
- apiRequest(`/api/v1/brands/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }),
- patchScanPostOutreach: (id: string, postId: string, body: { outreach_status?: string }) =>
- apiRequest(`/api/v1/brands/${id}/scan-posts/${postId}`, { method: "PATCH", body, auth: true }),
-
- // Content Matrix
- getBrandContentMatrix: (id: string) => apiRequest(`/api/v1/brands/${id}/content-matrix`, { auth: true }),
- generateBrandContentMatrix: (id: string, body?: { count?: number }) =>
- apiRequest(`/api/v1/brands/${id}/content-matrix/generate`, { method: "POST", body: body || {}, auth: true }),
-
- // Scan Schedule
- getBrandScanSchedule: (id: string) => apiRequest(`/api/v1/brands/${id}/scan-schedule`, { auth: true }),
- upsertBrandScanSchedule: (id: string, body: { cron?: string; timezone?: string; enabled: boolean }) =>
- apiRequest(`/api/v1/brands/${id}/scan-schedule`, { method: "PUT", body, auth: true }),
-
- // Legacy simple (PatrolPage still uses these names)
- brandScanPosts: (id: string) => apiRequest<{ list: ScanPost[]; total: number }>(`/api/v1/brands/${id}/scan-posts?limit=20`, { auth: true }),
-
- // Placement Topics (海巡主題) - 主要用於海巡頁:新增主題、爬文、知識圖譜
- placementTopics: () => apiRequest("/api/v1/placement/topics", { auth: true }),
- createPlacementTopic: (body: { brand_id: string; topic_name: string; seed_query: string; brief: string; product_id?: string }) =>
- apiRequest("/api/v1/placement/topics", { method: "POST", body, auth: true }),
- getPlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { auth: true }),
- updatePlacementTopic: (id: string, body: Record) =>
- apiRequest(`/api/v1/placement/topics/${id}`, { method: "PATCH", body, auth: true }),
- deletePlacementTopic: (id: string) => apiRequest(`/api/v1/placement/topics/${id}`, { method: "DELETE", auth: true }),
-
- // Placement Topic Knowledge Graph & Scan & Outreach
- expandPlacementTopicGraph: (id: string, body: { seed_query: string; supplemental?: boolean; regenerate_map?: boolean; expand_strategy?: string }) =>
- apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/expand`, { method: "POST", body, auth: true }),
- getPlacementTopicGraph: (id: string) => apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph`, { auth: true }),
- patchPlacementTopicGraphNodes: (id: string, body: { updates: Array<{ node_id: string; selected_for_scan?: boolean; relevance_tags?: string[]; recency_tags?: string[] }> }) =>
- apiRequest(`/api/v1/placement/topics/${id}/knowledge-graph/nodes`, { method: "PATCH", body, auth: true }),
-
- startPlacementTopicScan: (
- id: string,
- body: {
- graph_id?: string;
- node_ids?: string[];
- dual_track?: boolean;
- patrol_mode?: boolean;
- test_patrol?: boolean;
- patrol_keywords?: string[];
- }
- ) =>
- apiRequest<{ job_id: string; status: string; message?: string }>(`/api/v1/placement/topics/${id}/scan-jobs`, { method: "POST", body, auth: true }),
- listPlacementTopicScanPosts: (id: string, params?: { priority?: string; recent_7d?: boolean; product_fit_min?: number; limit?: number }) => {
- const qs = new URLSearchParams();
- if (params?.priority) qs.set("priority", params.priority);
- if (params?.recent_7d) qs.set("recent_7d", "true");
- if (params?.product_fit_min != null) qs.set("product_fit_min", String(params.product_fit_min));
- if (params?.limit) qs.set("limit", String(params.limit));
- const q = qs.toString() ? `?${qs.toString()}` : "";
- return apiRequest(`/api/v1/placement/topics/${id}/scan-posts${q}`, { auth: true });
- },
-
- generatePlacementTopicOutreachDrafts: (id: string, body: { scan_post_id: string; count?: number; voice_persona_id?: string; product_id?: string }) =>
- apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/generate`, { method: "POST", body, auth: true }),
- startPlacementTopicOutreachDraftJobs: (
- id: string,
- body: {
- scan_post_id?: string;
- scan_post_ids?: string[];
- count?: number;
- voice_persona_id: string;
- product_id?: string;
- regenerate?: boolean;
- },
- ) =>
- apiRequest<{ jobs: Array<{ job_id: string; status: string; message: string }>; message: string }>(
- `/api/v1/placement/topics/${id}/outreach-draft-jobs`,
- { method: "POST", body, auth: true },
- ),
- publishPlacementTopicOutreachDraft: (id: string, body: { scan_post_id: string; text: string; confirm: boolean }) =>
- apiRequest(`/api/v1/placement/topics/${id}/outreach-drafts/publish`, { method: "POST", body, auth: true }),
- updatePlacementTopicOutreachDraft: (topicId: string, draftId: string, body: { draft_index: number; text: string }) =>
- apiRequest(`/api/v1/placement/topics/${topicId}/outreach-drafts/${draftId}`, { method: "PATCH", body, auth: true }),
- deletePlacementTopicScanPost: (topicId: string, postId: string) =>
- apiRequest(`/api/v1/placement/topics/${topicId}/scan-posts/${postId}`, { method: "DELETE", auth: true }),
- batchDeletePlacementTopicScanPosts: (topicId: string, postIds: string[]) =>
- apiRequest<{ deleted_count: number }>(`/api/v1/placement/topics/${topicId}/scan-posts/batch-delete`, { method: "POST", body: { post_ids: postIds }, auth: true }),
-
- jobs: (page = 1, pageSize = 30) => apiRequest(`/api/v1/jobs?page=${page}&pageSize=${pageSize}`, { auth: true }),
- getJob: (id: string) => apiRequest(`/api/v1/jobs/${id}`, { auth: true }),
- jobEvents: (jobId: string, limit = 40) =>
- apiRequest<{ list: JobEvent[] }>(`/api/v1/jobs/${jobId}/events?limit=${limit}`, { auth: true }),
- cancelJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/cancel`, { method: "POST", auth: true }),
- retryJob: (id: string) => apiRequest(`/api/v1/jobs/${id}/retry`, { method: "POST", auth: true }),
- jobSchedules: () => apiRequest<{ list: JobSchedule[]; pagination: Pagination }>("/api/v1/job/schedules?page=1&pageSize=50", { auth: true }),
- settings: (scope: string, scopeId: string) => apiRequest<{ list: SettingValue[]; pagination: Pagination }>(`/api/v1/settings/${scope}/${scopeId}`, { auth: true }),
- permissions: () => apiRequest("/api/v1/permissions/me?include_tree=true", { auth: true })
-};
diff --git a/old/backend/web/src/auth/AuthContext.tsx b/old/backend/web/src/auth/AuthContext.tsx
deleted file mode 100644
index 241af26..0000000
--- a/old/backend/web/src/auth/AuthContext.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
-import { apiRequest, clearTokens, getAccessToken, setTokens, setUnauthorizedHandler } from "../api/client";
-
-export type MemberMe = {
- tenant_id: string;
- uid: string;
- email: string;
- email_verified: boolean;
- origin?: string;
- display_name?: string;
- status: string;
- roles?: string[];
-};
-
-type AuthContextValue = {
- member: MemberMe | null;
- loading: boolean;
- login: (tenantId: string, email: string, password: string) => Promise;
- logout: () => Promise;
- reloadMember: () => Promise;
-};
-
-const AuthContext = createContext(null);
-
-type TokenPair = {
- access_token: string;
- refresh_token: string;
-};
-
-export function AuthProvider({ children }: { children: React.ReactNode }) {
- const [member, setMember] = useState(null);
- const [loading, setLoading] = useState(true);
-
- const reloadMember = useCallback(async (opts?: { throwOnError?: boolean }) => {
- if (!getAccessToken()) {
- setMember(null);
- setLoading(false);
- return;
- }
- setLoading(true);
- try {
- const data = await apiRequest("/api/v1/members/me", { auth: true });
- setMember(data);
- } catch (err) {
- clearTokens();
- setMember(null);
- if (opts?.throwOnError) {
- throw err;
- }
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- setUnauthorizedHandler(() => {
- clearTokens();
- setMember(null);
- });
- void reloadMember();
- return () => setUnauthorizedHandler(undefined);
- }, [reloadMember]);
-
- const login = useCallback(
- async (tenantId: string, email: string, password: string) => {
- const data = await apiRequest("/api/v1/auth/login", {
- method: "POST",
- body: { tenant_id: tenantId, email, password }
- });
- setTokens(data.access_token, data.refresh_token);
- await reloadMember({ throwOnError: true });
- },
- [reloadMember]
- );
-
- const logout = useCallback(async () => {
- try {
- await apiRequest("/api/v1/auth/logout", { method: "POST", auth: true });
- } catch {
- // Local logout still needs to complete if the server token is already invalid.
- }
- clearTokens();
- setMember(null);
- }, []);
-
- const value = useMemo(
- () => ({ member, loading, login, logout, reloadMember }),
- [member, loading, login, logout, reloadMember]
- );
-
- return {children};
-}
-
-export function useAuth() {
- const ctx = useContext(AuthContext);
- if (!ctx) {
- throw new Error("useAuth must be used within AuthProvider");
- }
- return ctx;
-}
diff --git a/old/backend/web/src/components/AcIcon.tsx b/old/backend/web/src/components/AcIcon.tsx
deleted file mode 100644
index f7bb8aa..0000000
--- a/old/backend/web/src/components/AcIcon.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-type IconName =
- | "home"
- | "account"
- | "publish"
- | "persona"
- | "mission"
- | "patrol"
- | "job"
- | "settings"
- | "spark"
- | "more"
- | "brand";
-
-const paths: Record = {
- home: "M4 11.5 12 4l8 7.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-8.5Z",
- account: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0H5Z",
- publish: "M4 19V5l16 7-16 7Zm3-4.4 7.9-2.6L7 9.4v3.1l5 1.5-5 .6Z",
- persona: "M7 5h10v4H7V5Zm-2 7h14v7H5v-7Zm3 2v3h2v-3H8Zm6 0v3h2v-3h-2Z",
- mission: "M5 5h14v14H5V5Zm3 4h8V7H8v2Zm0 4h8v-2H8v2Zm0 4h5v-2H8v2Z",
- patrol: "M12 3 4 7v6c0 4 3.3 7 8 8 4.7-1 8-4 8-8V7l-8-4Zm0 4 4 2v4c0 2.2-1.5 4-4 5-2.5-1-4-2.8-4-5V9l4-2Z",
- job: "M6 4h12v4H6V4Zm0 6h12v4H6v-4Zm0 6h12v4H6v-4Z",
- settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8.5 5.5v-3l-2.2-.4a7.7 7.7 0 0 0-.7-1.6l1.3-1.9-2.1-2.1-1.9 1.3c-.5-.3-1.1-.5-1.7-.7L12.8 3h-3l-.4 2.1c-.6.2-1.1.4-1.7.7L5.9 4.5 3.8 6.6l1.3 1.9c-.3.5-.5 1.1-.7 1.6l-2.2.4v3l2.2.4c.2.6.4 1.1.7 1.6l-1.3 1.9 2.1 2.1 1.9-1.3c.5.3 1.1.5 1.7.7l.4 2.1h3l.4-2.1c.6-.2 1.1-.4 1.7-.7l1.9 1.3 2.1-2.1-1.3-1.9c.3-.5.5-1.1.7-1.6l2.1-.4Z",
- spark: "M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8L12 2Zm6 13 1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z",
- more: "M5 12a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Zm7 0a2 2 0 1 0 0 .1V12Z",
- brand: "M4 7h16v2l-2 2v7a1 1 0 0 1-1 1h-4v-5H9v5H5a1 1 0 0 1-1-1v-7L4 9V7Zm2 4 1 1v6h2v-4h6v4h2v-6l1-1V9H6v2Z"
-};
-
-export function AcIcon({ name, size = 22 }: { name: IconName; size?: number }) {
- return (
-
- );
-}
diff --git a/old/backend/web/src/components/AuthDecor.tsx b/old/backend/web/src/components/AuthDecor.tsx
deleted file mode 100644
index e5f2eff..0000000
--- a/old/backend/web/src/components/AuthDecor.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-export function SceneDecor() {
- return (
-
- );
-}
-
-export function AuthTicketIcon() {
- return (
-
- );
-}
diff --git a/old/backend/web/src/components/AuthShell.tsx b/old/backend/web/src/components/AuthShell.tsx
deleted file mode 100644
index e33a37f..0000000
--- a/old/backend/web/src/components/AuthShell.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { AuthTicketIcon, SceneDecor } from "./AuthDecor";
-import { ThemeToggle } from "./ThemeToggle";
-
-export function AuthShell({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
-
-
-
-
-
-
巡樓 Console
-
把找題、寫文、排程、追成效串成一條線。
-
-
- {children}
-
-
-
- );
-}
diff --git a/old/backend/web/src/components/DevToolsPanel.tsx b/old/backend/web/src/components/DevToolsPanel.tsx
deleted file mode 100644
index 01dc77c..0000000
--- a/old/backend/web/src/components/DevToolsPanel.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-import { useEffect, useState } from "react";
-import { api, ThreadsConnection } from "../api/haixun";
-import { getAccessToken } from "../api/client";
-import { readActiveThreadsAccountId } from "../lib/activeAccount";
-import {
- isExtensionBridgePresent,
- pingExtensionBridge,
- requestExtensionSync,
- waitForExtensionBridge,
-} from "../lib/extensionSync";
-import { ExtensionInstallCard } from "./ExtensionInstallCard";
-import { Badge, Button, Card, Field, Textarea } from "./ui";
-
-type DevToolsPanelProps = {
- accountId: string;
- connection: ThreadsConnection;
- onConnectionChange: (data: ThreadsConnection) => void;
- onMessage: (message: string) => void;
- onError: (message: string) => void;
-};
-
-export function DevToolsPanel({
- accountId,
- connection,
- onConnectionChange,
- onMessage,
- onError,
-}: DevToolsPanelProps) {
- const [open, setOpen] = useState(true);
- const [extensionReady, setExtensionReady] = useState(false);
- const [syncBusy, setSyncBusy] = useState(false);
- const [manualState, setManualState] = useState("");
- const [importBusy, setImportBusy] = useState(false);
- const [showManualImport, setShowManualImport] = useState(false);
-
- useEffect(() => {
- let cancelled = false;
- const onBridgeMessage = (event: MessageEvent) => {
- if (event.source !== window) return;
- if (event.data?.type === "HAIXUN_EXTENSION_READY") setExtensionReady(true);
- };
- window.addEventListener("message", onBridgeMessage);
- const timer = window.setInterval(() => {
- if (cancelled) return;
- if (isExtensionBridgePresent()) {
- setExtensionReady(true);
- return;
- }
- pingExtensionBridge();
- }, 1500);
- pingExtensionBridge();
- void waitForExtensionBridge(6000).then((ready) => {
- if (!cancelled && ready) setExtensionReady(true);
- });
- return () => {
- cancelled = true;
- window.removeEventListener("message", onBridgeMessage);
- window.clearInterval(timer);
- };
- }, []);
-
- const reloadConnection = async () => {
- const data = await api.getThreadsConnection(accountId);
- onConnectionChange(data);
- return data;
- };
-
- const syncChromeSession = async () => {
- setSyncBusy(true);
- onError("");
- onMessage("");
- try {
- const ready = extensionReady || isExtensionBridgePresent() || (await waitForExtensionBridge(4000));
- if (!ready) {
- throw new Error(
- "找不到巡樓 Chrome 擴充。請到 chrome://extensions 載入 extension/haixun-threads-sync,按「重新載入」後刷新此頁(F5)",
- );
- }
- setExtensionReady(true);
-
- await api.memberMe();
- const resolvedAccountId = accountId || readActiveThreadsAccountId();
- if (!resolvedAccountId) {
- throw new Error("請先在頂部選擇經營帳號,或到 Threads 帳號頁設為使用中後再同步");
- }
-
- const result = await requestExtensionSync({
- accountId: resolvedAccountId,
- serverUrl: window.location.origin,
- });
- if (result.success === false || result.valid === false) {
- throw new Error(result.message || "Chrome session 同步失敗");
- }
- onMessage(result.message || "Chrome session 已同步");
- await reloadConnection();
- } catch (err) {
- onError(err instanceof Error ? err.message : "Chrome session 同步失敗");
- } finally {
- setSyncBusy(false);
- }
- };
-
- const importManualSession = async () => {
- const trimmed = manualState.trim();
- if (!trimmed) {
- onError("請貼上 Playwright storage state JSON");
- return;
- }
- setImportBusy(true);
- onError("");
- onMessage("");
- try {
- JSON.parse(trimmed);
- const data = await api.importThreadsAccountSession(accountId, trimmed);
- if (!data.valid) throw new Error(data.message || "Session 驗證失敗");
- setManualState("");
- onMessage(data.message || "Session 已匯入");
- await reloadConnection();
- } catch (err) {
- if (err instanceof SyntaxError) {
- onError("JSON 格式不正確,請貼上完整的 Playwright storage state");
- } else {
- onError(err instanceof Error ? err.message : "匯入失敗");
- }
- } finally {
- setImportBusy(false);
- }
- };
-
- return (
-
-
-
setOpen((v) => !v)}
- >
-
- 僅「測試海巡(爬蟲)」會用 Chrome 爬蟲;正式海巡、擴圖、發文與獲客留言一律走 Threads API。
-
- {open ? "收起 ▴" : "展開 ▾"}
-
-
- {open ? (
-
-
-
- {connection.browser_connected ? "Chrome Session 已同步" : "Chrome Session 未同步"}
-
-
- {extensionReady ? "擴充已偵測" : "尚未偵測擴充"}
-
-
-
-
- void syncChromeSession()}>
- 從 Chrome 同步 Session
-
- setShowManualImport((v) => !v)}>
- {showManualImport ? "收起手動匯入" : "手動貼 JSON"}
-
-
-
- {showManualImport ? (
-
-
-
- void importManualSession()}>
- 匯入 Session
-
-
- ) : null}
-
- {!extensionReady ? (
- <>
-
- 安裝擴充後到 chrome://extensions 重新載入,再按 F5 刷新此頁。巡樓網址須與擴充選項一致(localhost 與 127.0.0.1 也要一致)。
-
-
- >
- ) : null}
-
-
- 診斷:頁面 {window.location.origin}|JWT {getAccessToken() ? "有" : "無"}
-
-
- ) : null}
-
-
- );
-}
diff --git a/old/backend/web/src/components/ExtensionInstallCard.tsx b/old/backend/web/src/components/ExtensionInstallCard.tsx
deleted file mode 100644
index 627b3ab..0000000
--- a/old/backend/web/src/components/ExtensionInstallCard.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import {
- EXTENSION_DOWNLOAD_URL,
- EXTENSION_INSTALL_STEPS,
- EXTENSION_VERSION,
-} from "../lib/extensionInstall";
-import { Badge } from "./ui";
-
-type ExtensionInstallCardProps = {
- compact?: boolean;
- showSteps?: boolean;
-};
-
-export function ExtensionInstallCard({ compact, showSteps = true }: ExtensionInstallCardProps) {
- return (
-
-
-
-
Chrome 擴充套件
-
- 開發模式(爬蟲)需安裝此擴充,才能從 Chrome 同步 Threads 登入態到巡樓。
-
-
-
v{EXTENSION_VERSION}
-
-
- {showSteps ? (
-
- {EXTENSION_INSTALL_STEPS.map((step) => (
- -
- {step}
-
- ))}
-
- ) : null}
-
-
- 下載擴充套件(ZIP)
-
-
- );
-}
\ No newline at end of file
diff --git a/old/backend/web/src/components/HoverHelpTip.tsx b/old/backend/web/src/components/HoverHelpTip.tsx
deleted file mode 100644
index 5695cbe..0000000
--- a/old/backend/web/src/components/HoverHelpTip.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-type Props = {
- text: string;
- label?: string;
-};
-
-export function HoverHelpTip({ text, label = "查看原因" }: Props) {
- if (!text.trim()) return null;
-
- return (
-
-
- ?
-
-
- {text}
-
-
- );
-}
\ No newline at end of file
diff --git a/old/backend/web/src/components/JobMonitor.tsx b/old/backend/web/src/components/JobMonitor.tsx
deleted file mode 100644
index a5a7f1e..0000000
--- a/old/backend/web/src/components/JobMonitor.tsx
+++ /dev/null
@@ -1,336 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import { api, JobRun } from "../api/haixun";
-import { Badge, Button } from "./ui";
-import { formatNano } from "../lib/format";
-import { canCancelJob, canRetryJob } from "../lib/jobActions";
-import { jobTemplateLabel } from "../lib/jobLabels";
-import { jobStatusBadgeClass, jobStatusLabel } from "../lib/jobStatus";
-import { AcIcon } from "./AcIcon";
-import type { JobCreatedDetail } from "../lib/jobEvents";
-
-const ACTIVE_STATUSES = ["running", "queued", "pending", "waiting_worker", "cancel_requested"];
-const PANEL_STATUSES = [...ACTIVE_STATUSES, "failed"];
-
-function nowNano() {
- return Date.now() * 1_000_000;
-}
-
-function optimisticJob(detail: JobCreatedDetail): JobRun {
- return {
- id: detail.jobId,
- template_type: detail.templateType || detail.label || "任務",
- status: detail.status || "queued",
- progress: { percentage: 0, summary: "剛建立,等待執行…" },
- create_at: nowNano(),
- update_at: nowNano()
- };
-}
-
-function JobMonitorItem({
- job,
- actionLoading,
- onCancel,
- onRetry,
- showError
-}: {
- job: JobRun;
- actionLoading: boolean;
- onCancel: () => void;
- onRetry: () => void;
- showError: boolean;
-}) {
- return (
-
-
- {jobTemplateLabel(job.template_type)}
- {jobStatusLabel(job.status)}
-
-
- {job.progress ? (
-
- {job.progress.percentage ?? 0}%
- {job.progress.summary ? ` · ${job.progress.summary}` : ""}
-
- ) : null}
-
- {showError && job.error?.trim() ? (
-
- {job.error.trim()}
-
- ) : null}
-
-
-
{formatNano(job.update_at)}
-
- {canCancelJob(job) ? (
-
- 取消
-
- ) : null}
- {canRetryJob(job) ? (
-
- 重作
-
- ) : null}
-
-
-
- );
-}
-
-export function JobMonitor() {
- const [jobs, setJobs] = useState([]);
- const [open, setOpen] = useState(false);
- const [actionJobId, setActionJobId] = useState("");
- const [hot, setHot] = useState(false);
- const pendingRef = useRef