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} 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 ? ( <>
setCustomPw(e.target.value)} placeholder={t("admin.users.customPwPh")} diff --git a/apps/web/src/pages/CrewPage.tsx b/apps/web/src/pages/CrewPage.tsx index 35859b1..32936da 100644 --- a/apps/web/src/pages/CrewPage.tsx +++ b/apps/web/src/pages/CrewPage.tsx @@ -35,13 +35,6 @@ function accountHealth( const join = (...parts: string[]) => parts.filter(Boolean).join(" · "); // 授權/API 壞了:只顯示需重連,不看「到期還有效」 - if (acc.connection === "error" || !acc.is_usable) { - return { - tone: "danger", - label: t("crew.health.needsReconnect"), - detail: join(acc.error_message || t("crew.health.needsReconnectHint"), refreshLine), - }; - } if (acc.connection === "disconnected") { return { tone: "neutral", @@ -49,6 +42,13 @@ function accountHealth( detail: join(t("crew.health.disconnectedHint"), refreshLine), }; } + if (acc.connection === "error" || !acc.is_usable) { + return { + tone: "danger", + label: t("crew.health.needsReconnect"), + detail: join(acc.error_message || t("crew.health.needsReconnectHint"), refreshLine), + }; + } // connected + usable → 以到期時間為準 if (session.kind === "expired") { return { @@ -88,6 +88,7 @@ export function CrewPage() { const [accPage, setAccPage] = useState(1); const [busy, setBusy] = useState(""); const [message, setMessage] = useState(""); + const [error, setError] = useState(""); const load = useCallback(async () => { setAccounts(await repos.accounts.list()); @@ -107,11 +108,13 @@ export function CrewPage() { const oauth = q.get("oauth"); if (!oauth) return; if (oauth === "ok") { + setError(""); setMessage(t("crew.msg.oauthOk")); void load(); refresh(); } else { - setMessage(q.get("msg") || t("crew.msg.oauthFail")); + setMessage(""); + setError(q.get("msg") || t("crew.msg.oauthFail")); } // 清 query,避免刷新重複提示 const url = new URL(window.location.href); @@ -123,6 +126,7 @@ export function CrewPage() { async function connectAccount() { setBusy("create"); setMessage(""); + setError(""); try { const { authorize_url } = await repos.accounts.oauthStart(); if (authorize_url) { @@ -131,7 +135,7 @@ export function CrewPage() { } throw new Error(t("crew.msg.oauthUrlFail")); } catch (e) { - setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail")); + setError(e instanceof Error ? e.message : t("crew.msg.refreshFail")); } finally { setBusy(""); } @@ -143,13 +147,29 @@ export function CrewPage() { } setBusy(`del:${acc.id}`); setMessage(""); + setError(""); try { await repos.accounts.remove(acc.id); setMessage(t("crew.msg.deleted", { user: acc.username })); refresh(); await load(); } catch (e) { - setMessage(e instanceof Error ? e.message : t("crew.msg.refreshFail")); + setError(e instanceof Error ? e.message : t("crew.msg.refreshFail")); + } finally { + setBusy(""); + } + } + + async function refreshAccount(acc: ThreadsAccount) { + setBusy(`refresh:${acc.id}`); + setMessage(""); + setError(""); + try { + await repos.accounts.refreshSession(acc.id); + setMessage(t("crew.msg.refreshed", { user: acc.username })); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : t("crew.msg.refreshFail")); } finally { setBusy(""); } @@ -192,6 +212,11 @@ export function CrewPage() { {message}

) : null} + {error ? ( +

+ {error} +

+ ) : null} {accounts.length === 0 ? ( @@ -217,6 +242,16 @@ export function CrewPage() { ) : null}
+ - + {t("forgot.openReset")}
- - - + {t("forgot.back")} - + {t("common.back")} } /> ); @@ -170,10 +168,8 @@ export function JobDetailPage() { ) : null} {job.template_type === "compose_mimic" ? ( - - + + {t("jobs.backCompose")} ) : null} {canDeleteJob(job) ? ( @@ -186,11 +182,7 @@ export function JobDetailPage() { {deleting ? t("common.loading") : t("jobs.delete")} ) : null} - - - + {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")} {canDeleteJob(job) ? ( - - + + {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}
- +

{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 ? ( + + ) : 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} - +
    + + +
    ) : (

    {t("common.loading")}

    @@ -378,9 +428,19 @@ export function SettingsPage() { }} /> ) : null} - +
    + + +
    ) : (

    {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 })} +
    • + ))} +
    +
    + - - - - - - -
    -

    2. Threads API 連線(多帳號)

    -

    每位會員可綁定多個 Threads 帳號;token 存 Mongo,系統會在到期前自動 refresh

    -

    選中帳號:—

    -
    - - - - - - -
    -
      -
      - OAuth 除錯 log - - 授權後若仍顯示未連線,先看這裡的 stage -
      -
      (載入中…)
      -

      Meta Redirect URI:載入中…

      -
      - 產品連線設定 - - - - -
      -
      - Threads 權限煙霧測試 - - 讀取類 API 自動測;發文/刪文/隱藏留言需手動 -
      -
        -
        - -
        -
        -
        -
        -

        貼文成效追蹤

        -

        瀏覽、互動與帳號洞察一次看完。數字來自 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
        -
        -
        -
        -

        結果會顯示在下方;成功後可把 media_id 複製到其他卡片繼續測。

        -
        (尚無結果)
        -
        - -
        -

        3. AI 設定(8D 研究用)

        -

        登入後自動載入經營帳號與 AI 設定

        -
        - - -
        -
        - - -
        -
        - - - - -
        -
        - - - -
        -

        8D 分析使用研究用 provider / model。Key 存於後端(會員 scope),Dev Console 不會把完整 key 留在 localStorage。

        -
        - -
        -

        4. 人設

        -
        - - - - -
        -
          -

          點選一筆人設後,下方可跑 8D 分析。

          -
          - -
          -

          5. 8D 風格分析(公開爬蟲,不需 session)

          -
          - - - -
          -

          需先完成上方 AI 設定,並啟動 Go API + Node worker(style-8d)。

          - -
          -
          - - - - -
          -
          -
          (尚無任務)
          -
          - -
          -

          6. 人設詳情

          -
          - - -
          -
          -

          選中人設後顯示內容

          -
          -
          - -
          -

          Log

          -
          
          -      
          - - - - - \ 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: