From baba2095191bd64514ebd575e098ad81a22c1f39 Mon Sep 17 00:00:00 2001 From: wtgoku <2687861+wtgoku@users.noreply.github.com> Date: Thu, 21 May 2026 16:53:07 +0800 Subject: [PATCH] Expose gateway billing flows from the synced server baseline The preserved local work adds PopiNewAPI billing checkout, gateway account binding, and matching web console/pricing surfaces on top of the current test-server baseline. During the merge, the start-end video helpers from the deployed baseline were kept alongside the actionGenerate prompt handling from the WIP. Constraint: Current usable baseline is 7054436, already deployed on the test server. Rejected: Commit the WIP before syncing the baseline | would have hidden conflicts with the deployed start-end-frame changes. Confidence: medium Scope-risk: broad Directive: Do not deploy this commit to the test server without rechecking gateway credentials and billing checkout behavior in that environment. Tested: go test ./... Tested: make build Tested: cd web && npm run build --- README.md | 66 ++ internal/server/billing_test.go | 91 ++ internal/server/gateway_billing_test.go | 303 +++++ internal/server/models_test.go | 16 +- internal/server/newapi.go | 1040 ++++++++++++++++- internal/server/newapi_image_test.go | 10 + internal/server/newapi_music_test.go | 81 ++ internal/server/newapi_speech_test.go | 61 + internal/server/repository.go | 1 + internal/server/routes_test.go | 50 +- internal/server/server.go | 766 ++++++++++++- internal/server/sqlite_repo.go | 74 +- internal/server/store.go | 4 + internal/server/types.go | 36 +- internal/server/video_test.go | 393 +++++++ web/app/[locale]/billing/page.tsx | 385 +++++++ web/app/[locale]/billing/success/page.tsx | 106 ++ web/app/[locale]/console/page.tsx | 340 +++++- web/app/[locale]/pricing/page.tsx | 149 ++- web/app/api/auth/gateway-bind/route.ts | 67 ++ web/app/api/billing/checkout/route.ts | 150 +++ web/app/globals.css | 121 +- web/components/billing-page-state.tsx | 35 + web/components/billing-purchase-actions.tsx | 208 ++++ web/components/billing-refresh-listener.tsx | 74 ++ web/components/gateway-bind-form.tsx | 127 +++ web/components/main-nav.tsx | 4 + web/components/site-chrome.tsx | 2 + web/components/vendor/qrcode-react.d.ts | 11 + web/components/vendor/qrcode-react.js | 1137 +++++++++++++++++++ web/lib/popiart-api.ts | 213 ++++ 31 files changed, 6035 insertions(+), 86 deletions(-) create mode 100644 internal/server/billing_test.go create mode 100644 internal/server/gateway_billing_test.go create mode 100644 internal/server/newapi_music_test.go create mode 100644 internal/server/newapi_speech_test.go create mode 100644 web/app/[locale]/billing/page.tsx create mode 100644 web/app/[locale]/billing/success/page.tsx create mode 100644 web/app/api/auth/gateway-bind/route.ts create mode 100644 web/app/api/billing/checkout/route.ts create mode 100644 web/components/billing-page-state.tsx create mode 100644 web/components/billing-purchase-actions.tsx create mode 100644 web/components/billing-refresh-listener.tsx create mode 100644 web/components/gateway-bind-form.tsx create mode 100644 web/components/vendor/qrcode-react.d.ts create mode 100644 web/components/vendor/qrcode-react.js diff --git a/README.md b/README.md index 06b4cc6..c678f2a 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,72 @@ go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 media upload ./source.p go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 artifacts upload ./source.png --role source ``` +### Jimeng action transfer + +`popiartServer` supports the `popiart video action-transfer` CLI path by routing +`jimeng*` / DreamActor video model requests through PopiNewAPI's unified video +gateway: + +```text +popiartcli + -> POST /v1/models/infer + -> popiartServer video.image2video job + -> PopiNewAPI POST /v1/video/generations + -> PopiNewAPI GET /v1/video/generations/{task_id} +``` + +CLI usage: + +```sh +popiart --endpoint http://127.0.0.1:8080/v1 video action-transfer \ + --image ./face.jpg \ + --video ./source-action.mp4 \ + --cut-result-first-second-switch \ + --wait \ + --output json \ + --quiet \ + --non-interactive +``` + +Request mapping: + +- `model_id=jimeng_dreamactor_m20_gen_video` +- `model_type=video` +- `input.images[0]` becomes gateway `images[0]` +- `input.videos[0]` becomes gateway `videos[0]` +- `metadata.action` defaults to `actionGenerate` for DreamActor +- `--cut-result-first-second-switch` maps to `metadata.cut_result_first_second_switch` +- Image data URLs are normalized to pure base64 before submission because Jimeng rejects the `data:image/*;base64,` prefix. + +The test server at `http://101.42.99.35:18080/v1` has been verified with a 5 second action-transfer preview returning an MP4 artifact. + +### Seedance / Doubao video + +`popiartServer` also supports Seedance / Doubao video models through the same +`video.image2video` job lane, but submits them to PopiNewAPI's unified JSON +video generations endpoint instead of the OpenAI-style `/v1/videos` path. + +CLI usage: + +```sh +popiart --endpoint http://127.0.0.1:8080/v1 video seedance \ + --prompt "keep the motion style consistent" \ + --video https://example.com/ref.mp4 \ + --ratio 16:9 \ + --return-last-frame \ + --wait \ + --output json \ + --quiet \ + --non-interactive +``` + +Request mapping: + +- default model: `doubao-seedance-2-0-260128` +- `images[]`, `videos[]`, `audios[]` pass through to gateway JSON input +- `size` and `duration` stay at top level +- `action`, `frames`, `ratio`, `return_last_frame`, `generate_audio`, `service_tier`, and related options map into `metadata` + Optional skillhub source: ```sh diff --git a/internal/server/billing_test.go b/internal/server/billing_test.go new file mode 100644 index 0000000..0dc814e --- /dev/null +++ b/internal/server/billing_test.go @@ -0,0 +1,91 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestHandleBillingCatalogReturnsSeededPlans(t *testing.T) { + server, err := NewWithConfig(Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/catalog", nil) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Data struct { + Plans []billingPlan `json:"plans"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK { + t.Fatalf("expected ok response, got %s", rec.Body.String()) + } + if len(resp.Data.Plans) != 2 { + t.Fatalf("expected 2 plans, got %d", len(resp.Data.Plans)) + } + if resp.Data.Plans[0].ProductType != "subscription" { + t.Fatalf("expected first plan to be subscription, got %#v", resp.Data.Plans[0]) + } +} + +func TestHandleBillingSubscriptionReturnsUnbound(t *testing.T) { + server, err := NewWithConfig(Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/subscription", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("expected 412, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode error response: %v", err) + } + if resp.OK { + t.Fatalf("expected error response, got %s", rec.Body.String()) + } + if resp.Error.Code != "BILLING_UNBOUND" { + t.Fatalf("expected BILLING_UNBOUND, got %q", resp.Error.Code) + } +} diff --git a/internal/server/gateway_billing_test.go b/internal/server/gateway_billing_test.go new file mode 100644 index 0000000..7513d72 --- /dev/null +++ b/internal/server/gateway_billing_test.go @@ -0,0 +1,303 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestHandleAuthGatewayBindPersistsGatewayBinding(t *testing.T) { + var gotAuth string + var gotUser string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/user/self" { + http.NotFound(w, r) + return + } + gotAuth = r.Header.Get("Authorization") + gotUser = r.Header.Get("New-Api-User") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"id":123,"username":"weitao","display_name":"Wei Tao","email":"weitao@example.com"}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/gateway/bind", strings.NewReader(`{"gateway_user_id":123,"gateway_access_token":"gate_tok_123"}`)) + req.Header.Set("Authorization", "Bearer "+sessionToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if gotAuth != "Bearer gate_tok_123" { + t.Fatalf("expected bearer gateway access token, got %q", gotAuth) + } + if gotUser != "123" { + t.Fatalf("expected New-Api-User 123, got %q", gotUser) + } + + current, exists, err := server.store.session(sessionToken) + if err != nil { + t.Fatalf("session: %v", err) + } + if !exists { + t.Fatal("expected session to exist") + } + if current.GatewayUserID != 123 || current.GatewayAccessToken != "gate_tok_123" { + t.Fatalf("unexpected gateway binding in session: %#v", current) + } +} + +func TestHandleBillingSubscriptionUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/subscription/self" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer gate_tok_123" { + t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("New-Api-User") != "123" { + t.Fatalf("unexpected New-Api-User: %q", r.Header.Get("New-Api-User")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"billing_preference":"subscription","subscriptions":[{"subscription":{"id":10,"status":"active"}}],"all_subscriptions":[],"subscription_points":{"10":{"available_points":500}}}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/subscription", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp struct { + OK bool `json:"ok"` + Data struct { + BillingPreference string `json:"billing_preference"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK || resp.Data.BillingPreference != "subscription" { + t.Fatalf("unexpected response: %s", rec.Body.String()) + } +} + +func TestHandleBillingCreditsReturnsPreconditionWithoutGatewayBinding(t *testing.T) { + server, err := NewWithConfig(Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/credits", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("expected 412, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBillingSubscriptionPlansUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/subscription/plans" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":[{"plan":{"id":1,"title":"Pro Monthly","subtitle":"月付订阅","description":"说明","price_amount":29.9,"currency":"USD","points_amount":1000,"duration_unit":"month","duration_value":1,"recommended":true,"member_level":"pro","total_amount":500000,"quota_reset_period":"monthly"}}]}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/subscription/plans", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBillingCheckoutPointsUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/points/alipay/pay" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer gate_tok_123" { + t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("New-Api-User") != "123" { + t.Fatalf("unexpected New-Api-User: %q", r.Header.Get("New-Api-User")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"message":"success","trade_no":"PTSUSR1NOxxxx","url":"https://openapi.alipay.com/gateway.do?..."}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/billing/checkout/points", strings.NewReader(`{"package_id":1,"provider":"alipay","return_url":"https://example.com/pay-return"}`)) + req.Header.Set("Authorization", "Bearer "+sessionToken) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleBillingCheckoutStatusUsesGatewayBinding(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/points/alipay/query" { + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("trade_no"); got != "PTSUSR1NOxxxx" { + t.Fatalf("unexpected trade_no: %q", got) + } + if r.Header.Get("Authorization") != "Bearer gate_tok_123" { + t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("New-Api-User") != "123" { + t.Fatalf("unexpected New-Api-User: %q", r.Header.Get("New-Api-User")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"message":"","data":{"message":"success","status":"TRADE_SUCCESS","data":{}}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + sessionToken, _, ok, err := server.store.createSession("sk-billing-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + if err := server.store.bindGatewaySession(sessionToken, 123, "gate_tok_123"); err != nil { + t.Fatalf("bindGatewaySession: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/v1/billing/checkout/status?kind=points&provider=alipay&trade_no=PTSUSR1NOxxxx", nil) + req.Header.Set("Authorization", "Bearer "+sessionToken) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/models_test.go b/internal/server/models_test.go index 14d1858..ed0023c 100644 --- a/internal/server/models_test.go +++ b/internal/server/models_test.go @@ -25,6 +25,7 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { {"id":"seedream-4-5-251128","owned_by":"custom","supported_endpoint_types":[]}, {"id":"sora-2","owned_by":"openai","supported_endpoint_types":["openai-video"]}, {"id":"viduq2","owned_by":"custom","supported_endpoint_types":[]}, + {"id":"doubao-seedance-2-0-260128","owned_by":"custom","supported_endpoint_types":[]}, {"id":"custom-i2v-preview","owned_by":"custom","supported_endpoint_types":[]}, {"id":"gpt-4.1","owned_by":"openai","supported_endpoint_types":["openai"]} ] @@ -74,8 +75,8 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { if !resp.OK { t.Fatalf("expected ok response, got %s", rec.Body.String()) } - if len(resp.Data.Items) != 5 { - t.Fatalf("expected 5 supported media models, got %d: %s", len(resp.Data.Items), rec.Body.String()) + if len(resp.Data.Items) != 6 { + t.Fatalf("expected 6 supported media models, got %d: %s", len(resp.Data.Items), rec.Body.String()) } byID := make(map[string]model, len(resp.Data.Items)) @@ -133,6 +134,17 @@ func TestHandleModelsLoadsDynamicCatalogFromNewAPI(t *testing.T) { t.Fatalf("expected viduq2 provider vidu, got %q", viduModel.Provider) } + seedanceModel, exists := byID["doubao-seedance-2-0-260128"] + if !exists { + t.Fatal("expected doubao-seedance-2-0-260128 in dynamic model list") + } + if seedanceModel.Type != "video" { + t.Fatalf("expected doubao-seedance-2-0-260128 type video, got %q", seedanceModel.Type) + } + if seedanceModel.Provider != "volcengine" { + t.Fatalf("expected doubao-seedance-2-0-260128 provider volcengine, got %q", seedanceModel.Provider) + } + fallbackModel, exists := byID["custom-i2v-preview"] if !exists { t.Fatal("expected fallback-classified video model in dynamic model list") diff --git a/internal/server/newapi.go b/internal/server/newapi.go index 7eef994..c2f29df 100644 --- a/internal/server/newapi.go +++ b/internal/server/newapi.go @@ -101,6 +101,24 @@ type imageGenerationResponse struct { } `json:"error"` } +type musicGenerationResponse struct { + AudioURL string `json:"audio_url"` + Status int `json:"status"` + ExtraInfo map[string]any `json:"extra_info"` + Error *apiErrorBody `json:"error"` +} + +type speechGenerationResponse struct { + Error *apiErrorBody `json:"error"` +} + +type apiErrorBody struct { + Code any `json:"code"` + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` +} + type geminiImageGenerationResponse struct { Candidates []struct { Content struct { @@ -117,7 +135,7 @@ type geminiImageGenerationResponse struct { Error *struct { Message string `json:"message"` Status string `json:"status"` - Code int `json:"code"` + Code any `json:"code"` } `json:"error"` } @@ -130,6 +148,84 @@ type modelListResponse struct { } `json:"error"` } +type gatewayEnvelope[T any] struct { + Success bool `json:"success"` + Message string `json:"message"` + Data T `json:"data"` +} + +type gatewayUserSelf struct { + ID int `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` +} + +type gatewaySubscriptionSelf struct { + BillingPreference string `json:"billing_preference"` + Subscriptions []map[string]any `json:"subscriptions"` + AllSubscriptions []map[string]any `json:"all_subscriptions"` + SubscriptionPoints map[string]any `json:"subscription_points"` +} + +type gatewayPointsMine struct { + Wallets []map[string]any `json:"wallets"` + Balance int `json:"balance"` +} + +type gatewayPagedItems struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` + Items []map[string]any `json:"items"` +} + +type gatewaySubscriptionPlan struct { + ID int `json:"id"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Description string `json:"description"` + PriceAmount float64 `json:"price_amount"` + Currency string `json:"currency"` + PriceAmountCNY float64 `json:"price_amount_cny"` + PriceAmountUSD float64 `json:"price_amount_usd"` + PointsAmount int `json:"points_amount"` + DurationUnit string `json:"duration_unit"` + DurationValue int `json:"duration_value"` + Recommended bool `json:"recommended"` + MemberLevel string `json:"member_level"` + TotalAmount int `json:"total_amount"` + QuotaResetPeriod string `json:"quota_reset_period"` +} + +type gatewaySubscriptionPlanItem struct { + Plan gatewaySubscriptionPlan `json:"plan"` +} + +type gatewayPointsPackage struct { + ID int `json:"id"` + Name string `json:"name"` + Currency string `json:"currency"` + PriceAmount float64 `json:"price_amount"` + PointsAmount int `json:"points_amount"` + BonusPoints int `json:"bonus_points"` + Enabled bool `json:"enabled"` +} + +type gatewayPaymentIntent struct { + Message string `json:"message"` + TradeNo string `json:"trade_no"` + URL string `json:"url,omitempty"` + CodeURL string `json:"code_url,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + +type gatewayPaymentStatus struct { + Message string `json:"message"` + Status string `json:"status"` + Data map[string]any `json:"data,omitempty"` +} + type upstreamModel struct { ID string `json:"id"` Object string `json:"object,omitempty"` @@ -161,28 +257,34 @@ type openAIVideoResponse struct { } type taskEnvelopeResponse struct { - Code string `json:"code"` + Code any `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type taskDTOResponse struct { + ID string `json:"id,omitempty"` TaskID string `json:"task_id"` Status string `json:"status"` - Progress string `json:"progress"` + Progress any `json:"progress"` + Code any `json:"code,omitempty"` ResultURL string `json:"result_url,omitempty"` + URL string `json:"url,omitempty"` + VideoURL string `json:"video_url,omitempty"` FailReason string `json:"fail_reason,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` Data json.RawMessage `json:"data,omitempty"` } type videoTaskResult struct { - TaskID string - Status string - Progress string - URL string - Format string - ErrorCode string - ErrorReason string + TaskID string + Status string + Progress string + URL string + LastFrameURL string + Format string + ErrorCode string + ErrorReason string } func newNewAPIClient(cfg Config) *newAPIClient { @@ -318,6 +420,245 @@ func (c *newAPIClient) verifyKey(ctx context.Context, token string) error { return nil } +func (c *newAPIClient) gatewayRequest(ctx context.Context, method, path string, gatewayUserID int, gatewayAccessToken string, body io.Reader) ([]byte, error) { + if !c.enabled() { + return nil, errors.New("PopiNewAPI base URL is not configured") + } + if gatewayUserID <= 0 { + return nil, errors.New("gateway user id is required") + } + gatewayAccessToken = strings.TrimSpace(gatewayAccessToken) + if gatewayAccessToken == "" { + return nil, errors.New("gateway access token is required") + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+gatewayAccessToken) + req.Header.Set("New-Api-User", strconv.Itoa(gatewayUserID)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("PopiNewAPI returned status %d", resp.StatusCode) + } + return payload, nil +} + +func decodeGatewayEnvelope[T any](payload []byte, target *T) error { + var envelope gatewayEnvelope[T] + if err := json.Unmarshal(payload, &envelope); err != nil { + return err + } + if !envelope.Success { + return errors.New(strings.TrimSpace(envelope.Message)) + } + *target = envelope.Data + return nil +} + +func (c *newAPIClient) getGatewayUserSelf(ctx context.Context, gatewayUserID int, gatewayAccessToken string) (gatewayUserSelf, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/user/self", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayUserSelf{}, err + } + var self gatewayUserSelf + if err := decodeGatewayEnvelope(payload, &self); err != nil { + return gatewayUserSelf{}, err + } + return self, nil +} + +func (c *newAPIClient) getGatewaySubscriptionSelf(ctx context.Context, gatewayUserID int, gatewayAccessToken string) (gatewaySubscriptionSelf, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/subscription/self", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewaySubscriptionSelf{}, err + } + var item gatewaySubscriptionSelf + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewaySubscriptionSelf{}, err + } + return item, nil +} + +func (c *newAPIClient) getGatewayPointsMine(ctx context.Context, gatewayUserID int, gatewayAccessToken string) (gatewayPointsMine, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/points/mine", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPointsMine{}, err + } + var item gatewayPointsMine + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPointsMine{}, err + } + return item, nil +} + +func (c *newAPIClient) listGatewaySubscriptionOrders(ctx context.Context, gatewayUserID int, gatewayAccessToken string, query string) (gatewayPagedItems, error) { + path := "/api/subscription/orders" + if strings.TrimSpace(query) != "" { + path += "?" + query + } + payload, err := c.gatewayRequest(ctx, http.MethodGet, path, gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPagedItems{}, err + } + var item gatewayPagedItems + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPagedItems{}, err + } + return item, nil +} + +func (c *newAPIClient) listGatewayPointOrders(ctx context.Context, gatewayUserID int, gatewayAccessToken string, query string) (gatewayPagedItems, error) { + path := "/api/points/orders" + if strings.TrimSpace(query) != "" { + path += "?" + query + } + payload, err := c.gatewayRequest(ctx, http.MethodGet, path, gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPagedItems{}, err + } + var item gatewayPagedItems + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPagedItems{}, err + } + return item, nil +} + +func (c *newAPIClient) listGatewaySubscriptionPlans(ctx context.Context, gatewayUserID int, gatewayAccessToken string) ([]gatewaySubscriptionPlanItem, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/subscription/plans", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return nil, err + } + var items []gatewaySubscriptionPlanItem + if err := decodeGatewayEnvelope(payload, &items); err != nil { + return nil, err + } + return items, nil +} + +func (c *newAPIClient) listGatewayPointPackages(ctx context.Context, gatewayUserID int, gatewayAccessToken string) ([]gatewayPointsPackage, error) { + payload, err := c.gatewayRequest(ctx, http.MethodGet, "/api/points/packages", gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return nil, err + } + var items []gatewayPointsPackage + if err := decodeGatewayEnvelope(payload, &items); err != nil { + return nil, err + } + return items, nil +} + +func (c *newAPIClient) createGatewaySubscriptionPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider string, planID int, returnURL string) (gatewayPaymentIntent, error) { + path := map[string]string{ + "alipay": "/api/subscription/alipay/pay", + "wxpay": "/api/subscription/wxpay/pay", + }[provider] + if path == "" { + return gatewayPaymentIntent{}, errors.New("unsupported subscription payment provider") + } + payload, err := marshalJSON(map[string]any{ + "plan_id": planID, + "return_url": returnURL, + }) + if err != nil { + return gatewayPaymentIntent{}, err + } + body, err := c.gatewayRequest(ctx, http.MethodPost, path, gatewayUserID, gatewayAccessToken, strings.NewReader(payload)) + if err != nil { + return gatewayPaymentIntent{}, err + } + var item gatewayPaymentIntent + if err := decodeGatewayEnvelope(body, &item); err != nil { + return gatewayPaymentIntent{}, err + } + return item, nil +} + +func (c *newAPIClient) createGatewayPointsPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider string, packageID int, returnURL string) (gatewayPaymentIntent, error) { + path := map[string]string{ + "alipay": "/api/points/alipay/pay", + "wxpay": "/api/points/wxpay/pay", + }[provider] + if path == "" { + return gatewayPaymentIntent{}, errors.New("unsupported points payment provider") + } + payload, err := marshalJSON(map[string]any{ + "package_id": packageID, + "return_url": returnURL, + }) + if err != nil { + return gatewayPaymentIntent{}, err + } + body, err := c.gatewayRequest(ctx, http.MethodPost, path, gatewayUserID, gatewayAccessToken, strings.NewReader(payload)) + if err != nil { + return gatewayPaymentIntent{}, err + } + var item gatewayPaymentIntent + if err := decodeGatewayEnvelope(body, &item); err != nil { + return gatewayPaymentIntent{}, err + } + return item, nil +} + +func (c *newAPIClient) queryGatewaySubscriptionPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider, tradeNo string) (gatewayPaymentStatus, error) { + path := map[string]string{ + "alipay": "/api/subscription/alipay/query", + "wxpay": "/api/subscription/wxpay/query", + }[provider] + if path == "" { + return gatewayPaymentStatus{}, errors.New("unsupported subscription payment provider") + } + if strings.TrimSpace(tradeNo) == "" { + return gatewayPaymentStatus{}, errors.New("trade_no is required") + } + query := url.Values{} + query.Set("trade_no", tradeNo) + payload, err := c.gatewayRequest(ctx, http.MethodGet, path+"?"+query.Encode(), gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPaymentStatus{}, err + } + var item gatewayPaymentStatus + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPaymentStatus{}, err + } + return item, nil +} + +func (c *newAPIClient) queryGatewayPointsPayment(ctx context.Context, gatewayUserID int, gatewayAccessToken, provider, tradeNo string) (gatewayPaymentStatus, error) { + path := map[string]string{ + "alipay": "/api/points/alipay/query", + "wxpay": "/api/points/wxpay/query", + }[provider] + if path == "" { + return gatewayPaymentStatus{}, errors.New("unsupported points payment provider") + } + if strings.TrimSpace(tradeNo) == "" { + return gatewayPaymentStatus{}, errors.New("trade_no is required") + } + query := url.Values{} + query.Set("trade_no", tradeNo) + payload, err := c.gatewayRequest(ctx, http.MethodGet, path+"?"+query.Encode(), gatewayUserID, gatewayAccessToken, nil) + if err != nil { + return gatewayPaymentStatus{}, err + } + var item gatewayPaymentStatus + if err := decodeGatewayEnvelope(payload, &item); err != nil { + return gatewayPaymentStatus{}, err + } + return item, nil +} + func normalizeUpstreamModel(item upstreamModel) (model, bool) { modelID := strings.TrimSpace(item.ID) if modelID == "" { @@ -349,6 +690,12 @@ func normalizeUpstreamModel(item upstreamModel) (model, bool) { func inferProviderFallback(modelID string) string { lowerID := strings.ToLower(strings.TrimSpace(modelID)) switch { + case strings.Contains(lowerID, "doubao-seedance"), + strings.Contains(lowerID, "seedance"): + return "volcengine" + case strings.Contains(lowerID, "jimeng"), + strings.Contains(lowerID, "dreamactor"): + return "jimeng" case strings.Contains(lowerID, "vidu"): return "vidu" default: @@ -389,11 +736,15 @@ func classifyModelIDFallback(modelID string) (string, []string) { switch { case strings.Contains(lowerID, "sora"), strings.Contains(lowerID, "veo"), + strings.Contains(lowerID, "doubao-seedance"), + strings.Contains(lowerID, "seedance"), strings.Contains(lowerID, "vidu"), strings.Contains(lowerID, "runway"), strings.Contains(lowerID, "kling"), strings.Contains(lowerID, "pixverse"), strings.Contains(lowerID, "hailuo"), + strings.Contains(lowerID, "jimeng"), + strings.Contains(lowerID, "dreamactor"), strings.Contains(lowerID, "hunyuan-video"), strings.Contains(lowerID, "video"), strings.Contains(lowerID, "s2v"), @@ -481,6 +832,91 @@ func (c *newAPIClient) generateImageRefs(ctx context.Context, token, modelID str return decodeImageGenerationResponse(resp.StatusCode, respBody, modelID) } +func (c *newAPIClient) generateMusicRefs(ctx context.Context, token, modelID string, input map[string]any) ([]resultRef, map[string]any, error) { + if !c.enabled() { + return nil, nil, errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return nil, nil, errors.New("PopiNewAPI token is not configured") + } + payload := cloneMap(input) + payload["model"] = modelID + + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/music/generations", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + return decodeMusicGenerationResponse(resp.StatusCode, resp.Header.Get("Content-Type"), respBody, modelID) +} + +func (c *newAPIClient) generateSpeechRefs(ctx context.Context, token, modelID string, input map[string]any) ([]resultRef, map[string]any, error) { + if !c.enabled() { + return nil, nil, errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return nil, nil, errors.New("PopiNewAPI token is not configured") + } + prompt := strings.TrimSpace(stringValue(input["prompt"], input["text"], input["input"])) + if prompt == "" { + return nil, nil, errors.New("prompt is required") + } + format := defaultString(strings.TrimSpace(stringValue(input["format"], input["response_format"])), "mp3") + payload := map[string]any{ + "model": modelID, + "input": prompt, + "response_format": format, + } + if voice := strings.TrimSpace(stringValue(input["voice"])); voice != "" { + payload["voice"] = voice + } + if speed, ok := input["speed"]; ok { + payload["speed"] = speed + } + if metadata, ok := input["metadata"]; ok { + payload["metadata"] = metadata + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/audio/speech", bytes.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + return decodeSpeechGenerationResponse(resp.StatusCode, resp.Header.Get("Content-Type"), format, respBody, modelID) +} + func (c *newAPIClient) generateEditedImageRefs(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) ([]resultRef, map[string]any, error) { if !c.enabled() { return nil, nil, errors.New("PopiNewAPI base URL is not configured") @@ -1143,15 +1579,18 @@ func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) return nil, errors.New("PopiNewAPI token is not configured") } - result, err := c.fetchOpenAIVideoTask(ctx, token, taskID) - if err == nil { + result, err := c.fetchGenericVideoTask(ctx, token, taskID) + if err == nil && isUsableVideoTaskResult(result) { return result, nil } - fallback, fallbackErr := c.fetchGenericVideoTask(ctx, token, taskID) + fallback, fallbackErr := c.fetchOpenAIVideoTask(ctx, token, taskID) if fallbackErr == nil { return fallback, nil } - return nil, err + if err != nil { + return nil, err + } + return nil, fallbackErr } func (c *newAPIClient) fetchOpenAIVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { @@ -1189,6 +1628,9 @@ func (c *newAPIClient) fetchOpenAIVideoTask(ctx context.Context, token, taskID s if text := strings.TrimSpace(stringValue(decoded.Metadata["url"])); text != "" { result.URL = text } + if text := strings.TrimSpace(stringValue(decoded.Metadata["last_frame_url"])); text != "" { + result.LastFrameURL = text + } if text := strings.TrimSpace(stringValue(decoded.Metadata["format"])); text != "" { result.Format = text } @@ -1228,50 +1670,63 @@ func (c *newAPIClient) fetchGenericVideoTask(ctx context.Context, token, taskID if err := json.Unmarshal(body, &env); err != nil { return nil, fmt.Errorf("decode PopiNewAPI task envelope: %w", err) } - if strings.TrimSpace(env.Code) != "" && !strings.EqualFold(env.Code, "success") { + if !isTaskEnvelopeSuccessCode(env.Code) { return nil, errors.New(defaultString(strings.TrimSpace(env.Message), "PopiNewAPI video task fetch failed")) } + if result, ok := parseGenericVideoTaskPayload(env.Data, taskID); ok { + return result, nil + } + return nil, fmt.Errorf("decode PopiNewAPI generic task payload: missing task id") +} +func parseGenericVideoTaskPayload(raw json.RawMessage, fallbackTaskID string) (*videoTaskResult, bool) { var task taskDTOResponse - if err := json.Unmarshal(env.Data, &task); err == nil && strings.TrimSpace(task.TaskID) != "" { + if err := json.Unmarshal(raw, &task); err == nil && firstNonEmptyString(task.TaskID, task.ID, fallbackTaskID) != "" { return &videoTaskResult{ - TaskID: strings.TrimSpace(task.TaskID), - Status: normalizeVideoTaskStatus(task.Status), - Progress: normalizeVideoProgress(0, task.Progress), - URL: strings.TrimSpace(task.ResultURL), - ErrorReason: strings.TrimSpace(task.FailReason), - }, nil + TaskID: firstNonEmptyString(task.TaskID, task.ID, fallbackTaskID), + Status: normalizeGatewayVideoTaskStatus(task.Status, task.Code), + Progress: normalizeVideoProgressValue(task.Progress), + URL: firstNonEmptyString(task.ResultURL, task.URL, task.VideoURL, stringValue(task.Metadata["url"]), resultURLFromRawJSON(task.Data)), + LastFrameURL: strings.TrimSpace(stringValue(task.Metadata["last_frame_url"])), + ErrorReason: strings.TrimSpace(task.FailReason), + }, true } var simple struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - URL string `json:"url"` - Format string `json:"format"` - Progress string `json:"progress"` - Error *struct { + ID string `json:"id,omitempty"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Code any `json:"code,omitempty"` + URL string `json:"url"` + ResultURL string `json:"result_url,omitempty"` + VideoURL string `json:"video_url,omitempty"` + Format string `json:"format"` + Progress any `json:"progress"` + Metadata map[string]any `json:"metadata,omitempty"` + Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } - if err := json.Unmarshal(env.Data, &simple); err != nil { - return nil, fmt.Errorf("decode PopiNewAPI generic task payload: %w", err) + if err := json.Unmarshal(raw, &simple); err != nil { + return nil, false } result := &videoTaskResult{ - TaskID: strings.TrimSpace(simple.TaskID), - Status: normalizeVideoTaskStatus(simple.Status), - URL: strings.TrimSpace(simple.URL), - Format: strings.TrimSpace(simple.Format), - Progress: normalizeVideoProgress(0, simple.Progress), + TaskID: firstNonEmptyString(simple.TaskID, simple.ID, fallbackTaskID), + Status: normalizeGatewayVideoTaskStatus(simple.Status, simple.Code), + URL: firstNonEmptyString(simple.URL, simple.ResultURL, simple.VideoURL, stringValue(simple.Metadata["url"])), + LastFrameURL: strings.TrimSpace(stringValue(simple.Metadata["last_frame_url"])), + Format: strings.TrimSpace(simple.Format), + Progress: normalizeVideoProgressValue(simple.Progress), } if simple.Error != nil { result.ErrorCode = strings.TrimSpace(simple.Error.Code) result.ErrorReason = strings.TrimSpace(simple.Error.Message) } if result.TaskID == "" { - result.TaskID = taskID + return nil, false } - return result, nil + return result, true } func decodeImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) { @@ -1328,6 +1783,86 @@ func decodeImageGenerationResponse(statusCode int, body []byte, nameHint string) return nil, decoded.Usage, errors.New("PopiNewAPI returned neither b64_json nor url") } +func decodeMusicGenerationResponse(statusCode int, contentType string, body []byte, nameHint string) ([]resultRef, map[string]any, error) { + if statusCode >= 400 || strings.Contains(strings.ToLower(contentType), "json") { + var decoded musicGenerationResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, nil, fmt.Errorf("decode PopiNewAPI music response: %w", err) + } + if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { + return nil, decoded.ExtraInfo, errors.New(decoded.Error.Message) + } + if statusCode >= 400 { + return nil, decoded.ExtraInfo, fmt.Errorf("PopiNewAPI returned status %d", statusCode) + } + if strings.TrimSpace(decoded.AudioURL) == "" { + return nil, decoded.ExtraInfo, errors.New("PopiNewAPI returned no audio_url") + } + return []resultRef{{ + Kind: "url", + URL: decoded.AudioURL, + Filename: filenameFromURL(decoded.AudioURL, fmt.Sprintf("%s-%d.mp3", sanitizeFilename(nameHint), time.Now().UTC().Unix())), + ContentType: "audio/mpeg", + }}, decoded.ExtraInfo, nil + } + + if len(body) == 0 { + return nil, nil, errors.New("PopiNewAPI returned empty audio body") + } + contentType = defaultString(strings.TrimSpace(contentType), "audio/mpeg") + encoded := base64.StdEncoding.EncodeToString(body) + return []resultRef{{ + Kind: "data_url", + DataURL: "data:" + contentType + ";base64," + encoded, + Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)), + ContentType: contentType, + SizeBytes: int64(len(body)), + }}, nil, nil +} + +func decodeSpeechGenerationResponse(statusCode int, contentType, format string, body []byte, nameHint string) ([]resultRef, map[string]any, error) { + if statusCode >= 400 || strings.Contains(strings.ToLower(contentType), "json") { + var decoded speechGenerationResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, nil, fmt.Errorf("decode PopiNewAPI speech response: %w", err) + } + if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { + return nil, nil, errors.New(decoded.Error.Message) + } + if statusCode >= 400 { + return nil, nil, fmt.Errorf("PopiNewAPI returned status %d", statusCode) + } + return nil, nil, errors.New("PopiNewAPI returned JSON instead of audio") + } + if len(body) == 0 { + return nil, nil, errors.New("PopiNewAPI returned empty speech body") + } + contentType = defaultString(strings.TrimSpace(contentType), getContentTypeByAudioFormat(format)) + encoded := base64.StdEncoding.EncodeToString(body) + return []resultRef{{ + Kind: "data_url", + DataURL: "data:" + contentType + ";base64," + encoded, + Filename: fmt.Sprintf("%s-%d%s", sanitizeFilename(nameHint), time.Now().UTC().Unix(), extensionFromContentType(contentType)), + ContentType: contentType, + SizeBytes: int64(len(body)), + }}, nil, nil +} + +func getContentTypeByAudioFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "wav": + return "audio/wav" + case "aac": + return "audio/aac" + case "flac": + return "audio/flac" + case "pcm": + return "audio/pcm" + default: + return "audio/mpeg" + } +} + func decodeGeminiImageGenerationResponse(statusCode int, body []byte, nameHint string) ([]resultRef, map[string]any, error) { var decoded geminiImageGenerationResponse if err := json.Unmarshal(body, &decoded); err != nil { @@ -1395,6 +1930,18 @@ func useMiniMaxVideoGenerations(modelID string) bool { strings.Contains(lowerID, "minimax-hailuo") } +func useSeedanceVideoGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "doubao-seedance") || + strings.Contains(lowerID, "seedance") +} + +func useJimengVideoGenerations(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "jimeng") || + strings.Contains(lowerID, "dreamactor") +} + func resolveGeminiAspectRatio(input map[string]any) string { if aspectRatio := normalizeImageAspectRatio(stringValue(input["aspect_ratio"])); aspectRatio != "" { if _, ok := seedreamAspectRatioSizes[aspectRatio]; ok { @@ -1745,6 +2292,306 @@ func resolveMiniMaxVideoDuration(input map[string]any) int { return duration } +func (c *newAPIClient) submitSeedanceVideoTask(ctx context.Context, token, modelID string, input map[string]any) (string, error) { + if !c.enabled() { + return "", errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return "", errors.New("PopiNewAPI token is not configured") + } + + payload, err := buildSeedanceVideoGenerationPayload(modelID, input) + if err != nil { + return "", err + } + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + + taskID := decodeVideoSubmitTaskID(respBody) + if taskID == "" { + return "", errors.New("PopiNewAPI returned no task id") + } + return taskID, nil +} + +func buildSeedanceVideoGenerationPayload(modelID string, input map[string]any) (map[string]any, error) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return nil, errors.New("Seedance model id is required") + } + payload := map[string]any{ + "model": modelID, + } + if prompt := strings.TrimSpace(stringValue(input["prompt"])); prompt != "" { + payload["prompt"] = prompt + } + if size := strings.TrimSpace(stringValue(input["size"])); size != "" { + payload["size"] = size + } + if duration := resolvePositiveInt(input["duration"]); duration > 0 { + payload["duration"] = duration + } else if duration := resolvePositiveInt(input["duration_s"]); duration > 0 { + payload["duration"] = duration + } + if images := extractStringValues(input["images"]); len(images) > 0 { + payload["images"] = images + } + if videos := extractStringValues(input["videos"]); len(videos) > 0 { + payload["videos"] = videos + } + if audios := extractStringValues(input["audios"]); len(audios) > 0 { + payload["audios"] = audios + } + + metadata := map[string]any{} + if raw, ok := input["metadata"].(map[string]any); ok { + for key, value := range raw { + if strings.TrimSpace(key) == "" || value == nil { + continue + } + metadata[key] = value + } + } + for _, key := range []string{ + "action", + "seed", + "frames", + "ratio", + "return_last_frame", + "generate_audio", + "service_tier", + "execution_expires_after", + "draft", + "safety_identifier", + } { + if value, ok := input[key]; ok && value != nil { + metadata[key] = value + } + } + if tools, ok := input["tools"]; ok && tools != nil { + metadata["tools"] = tools + } + if len(metadata) > 0 { + payload["metadata"] = metadata + } + return payload, nil +} + +func (c *newAPIClient) submitJimengVideoTask(ctx context.Context, token, modelID string, input map[string]any) (string, error) { + if !c.enabled() { + return "", errors.New("PopiNewAPI base URL is not configured") + } + token = c.authorizedToken(token) + if token == "" { + return "", errors.New("PopiNewAPI token is not configured") + } + + payload, err := buildJimengVideoGenerationPayload(modelID, input) + if err != nil { + return "", err + } + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/video/generations", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + + taskID := decodeVideoSubmitTaskID(respBody) + if taskID == "" { + return "", errors.New("PopiNewAPI returned no task id") + } + return taskID, nil +} + +func buildJimengVideoGenerationPayload(modelID string, input map[string]any) (map[string]any, error) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return nil, errors.New("Jimeng model id is required") + } + payload := map[string]any{ + "model": modelID, + } + if prompt := strings.TrimSpace(stringValue(input["prompt"])); prompt != "" { + payload["prompt"] = prompt + } + if images := normalizeJimengImageInputs(extractStringValues(input["images"])); len(images) > 0 { + payload["images"] = images + } + if videos := extractStringValues(input["videos"]); len(videos) > 0 { + payload["videos"] = videos + } + if duration := resolveJimengDuration(input); duration > 0 { + payload["duration"] = duration + } + metadata := buildJimengMetadata(input) + if useJimengDreamActorModel(modelID) { + if _, ok := metadata["action"]; !ok { + metadata["action"] = "actionGenerate" + } + } + if len(metadata) > 0 { + payload["metadata"] = metadata + } + return payload, nil +} + +func useJimengDreamActorModel(modelID string) bool { + lowerID := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(lowerID, "dreamactor") +} + +func normalizeJimengImageInputs(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if stripped, ok := stripImageDataURLPrefix(value); ok { + value = stripped + } + out = append(out, value) + } + return out +} + +func stripImageDataURLPrefix(value string) (string, bool) { + value = strings.TrimSpace(value) + lower := strings.ToLower(value) + if !strings.HasPrefix(lower, "data:image/") { + return value, false + } + comma := strings.Index(value, ",") + if comma < 0 { + return value, true + } + return strings.TrimSpace(value[comma+1:]), true +} + +func resolveJimengDuration(input map[string]any) int { + duration := resolvePositiveInt(input["duration"]) + if duration == 0 { + duration = resolvePositiveInt(input["duration_s"]) + } + if duration == 0 { + duration = resolvePositiveInt(input["seconds"]) + } + if duration == 5 || duration == 10 { + return duration + } + return 0 +} + +func buildJimengMetadata(input map[string]any) map[string]any { + metadata := map[string]any{} + if raw, ok := input["metadata"].(map[string]any); ok { + for key, value := range raw { + if strings.TrimSpace(key) == "" || value == nil { + continue + } + metadata[key] = value + } + } + for _, key := range []string{ + "action", + "seed", + "frames", + "aspect_ratio", + "template_id", + "camera_strength", + "cut_result_first_second_switch", + } { + if value, ok := input[key]; ok && value != nil { + metadata[key] = value + } + } + if _, exists := metadata["frames"]; !exists { + switch resolveJimengDuration(input) { + case 5: + metadata["frames"] = 121 + case 10: + metadata["frames"] = 241 + } + } + return metadata +} + +func resolvePositiveInt(value any) int { + switch typed := value.(type) { + case int: + if typed > 0 { + return typed + } + case int32: + if typed > 0 { + return int(typed) + } + case int64: + if typed > 0 { + return int(typed) + } + case float64: + if typed > 0 { + return int(typed) + } + case json.Number: + parsed, err := typed.Int64() + if err == nil && parsed > 0 { + return int(parsed) + } + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + if err == nil && parsed > 0 { + return parsed + } + } + return 0 +} + func (c *newAPIClient) submitMiniMaxVideoTask(ctx context.Context, token, modelID string, input map[string]any, refs []imageEditReference) (string, error) { if !c.enabled() { return "", errors.New("PopiNewAPI base URL is not configured") @@ -1903,19 +2750,109 @@ func normalizeViduResolution(size string) string { func normalizeVideoTaskStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { - case "queued", "pending", "submitted": + case "queued", "pending", "submitted", "in_queue": return "queued" - case "processing", "in_progress", "running": + case "processing", "in_progress", "running", "generating": return "in_progress" case "completed", "done", "success", "succeeded": return "completed" - case "failed", "failure", "cancelled", "canceled": + case "failed", "failure", "cancelled", "canceled", "expired", "not_found": return "failed" default: return "" } } +func isUsableVideoTaskResult(result *videoTaskResult) bool { + if result == nil || strings.TrimSpace(result.TaskID) == "" { + return false + } + if strings.TrimSpace(result.Status) != "" { + return true + } + return strings.TrimSpace(result.URL) != "" +} + +func isTaskEnvelopeSuccessCode(code any) bool { + if code == nil { + return true + } + switch typed := code.(type) { + case string: + value := strings.TrimSpace(typed) + return value == "" || value == "0" || strings.EqualFold(value, "success") + case float64: + return typed == 0 + case int: + return typed == 0 + case int32: + return typed == 0 + case int64: + return typed == 0 + case json.Number: + parsed, err := typed.Int64() + return err == nil && parsed == 0 + default: + return false + } +} + +func normalizeGatewayVideoTaskStatus(status string, code any) string { + normalized := normalizeVideoTaskStatus(status) + if strings.EqualFold(strings.TrimSpace(status), "done") && !isGatewaySuccessCode(code) { + return "failed" + } + return normalized +} + +func isGatewaySuccessCode(code any) bool { + if code == nil { + return true + } + switch typed := code.(type) { + case int: + return typed == 10000 || typed == 0 + case int32: + return typed == 10000 || typed == 0 + case int64: + return typed == 10000 || typed == 0 + case float64: + return typed == 10000 || typed == 0 + case json.Number: + parsed, err := typed.Int64() + return err != nil || parsed == 10000 || parsed == 0 + case string: + value := strings.TrimSpace(typed) + return value == "" || value == "10000" || value == "0" + default: + return true + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if text := strings.TrimSpace(value); text != "" { + return text + } + } + return "" +} + +func resultURLFromRawJSON(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + return strings.TrimSpace(stringValue( + payload["result_url"], + payload["url"], + payload["video_url"], + )) +} + func normalizeVideoProgress(progress int, fallback string) string { if progress > 0 { return fmt.Sprintf("%d%%", progress) @@ -1927,6 +2864,29 @@ func normalizeVideoProgress(progress int, fallback string) string { return "" } +func normalizeVideoProgressValue(value any) string { + switch typed := value.(type) { + case int: + return normalizeVideoProgress(typed, "") + case int32: + return normalizeVideoProgress(int(typed), "") + case int64: + return normalizeVideoProgress(int(typed), "") + case float64: + return normalizeVideoProgress(int(typed), "") + case json.Number: + parsed, err := typed.Int64() + if err == nil { + return normalizeVideoProgress(int(parsed), "") + } + return "" + case string: + return normalizeVideoProgress(0, typed) + default: + return "" + } +} + func decodeTaskAPIError(body []byte, statusCode int) error { var taskErr struct { Code string `json:"code"` diff --git a/internal/server/newapi_image_test.go b/internal/server/newapi_image_test.go index 57b2e11..773cfc7 100644 --- a/internal/server/newapi_image_test.go +++ b/internal/server/newapi_image_test.go @@ -427,3 +427,13 @@ func TestGenerateGeminiImageRefsAnnotatesMultiImageRoles(t *testing.T) { t.Fatalf("unexpected style label: %#v", got) } } + +func TestDecodeGeminiImageGenerationResponseAcceptsStringErrorCode(t *testing.T) { + _, _, err := decodeGeminiImageGenerationResponse(http.StatusBadRequest, []byte(`{"error":{"code":"model_not_found","message":"model unavailable","status":"INVALID_ARGUMENT"}}`), "gemini-3.1-flash-image-preview") + if err == nil { + t.Fatal("expected Gemini error response to fail") + } + if !strings.Contains(err.Error(), "model unavailable") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/server/newapi_music_test.go b/internal/server/newapi_music_test.go new file mode 100644 index 0000000..e1b882c --- /dev/null +++ b/internal/server/newapi_music_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGenerateMusicRefsUsesMusicGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "audio_url": "https://example.com/music-output.mp3", + "status": 2, + "extra_info": { + "music_duration": 34, + "music_sample_rate": 44100, + "music_channel": 2, + "bitrate": 256000, + "music_size": 1098543 + } + }`)) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, usage, err := client.generateMusicRefs(context.Background(), "sk-test", "music-2.6", map[string]any{ + "prompt": "lofi rainy night piano", + "is_instrumental": true, + "output_format": "url", + "audio_setting": map[string]any{ + "format": "mp3", + }, + }) + if err != nil { + t.Fatalf("generateMusicRefs: %v", err) + } + if gotPath != "/v1/music/generations" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["model"] != "music-2.6" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + if gotBody["is_instrumental"] != true { + t.Fatalf("unexpected is_instrumental: %#v", gotBody["is_instrumental"]) + } + if len(refs) != 1 || refs[0].URL != "https://example.com/music-output.mp3" || refs[0].ContentType != "audio/mpeg" { + t.Fatalf("unexpected refs: %#v", refs) + } + if usage["music_duration"] != float64(34) { + t.Fatalf("unexpected usage: %#v", usage) + } +} + +func TestDecodeMusicGenerationResponseAcceptsNumericErrorCode(t *testing.T) { + _, _, err := decodeMusicGenerationResponse(http.StatusBadRequest, "application/json", []byte(`{ + "error": { + "message": "minimax music error: 1004 - auth failed", + "type": "bad_response", + "param": "", + "code": 1004 + } + }`), "music-2.6") + if err == nil || !strings.Contains(err.Error(), "auth failed") { + t.Fatalf("expected decoded upstream error, got %v", err) + } +} diff --git a/internal/server/newapi_speech_test.go b/internal/server/newapi_speech_test.go new file mode 100644 index 0000000..7037d52 --- /dev/null +++ b/internal/server/newapi_speech_test.go @@ -0,0 +1,61 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGenerateSpeechRefsUsesAudioSpeechEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "audio/mpeg") + _, _ = w.Write([]byte("fake-mp3")) + })) + defer srv.Close() + + client := &newAPIClient{ + baseURL: strings.TrimRight(srv.URL, "/"), + httpClient: srv.Client(), + } + + refs, _, err := client.generateSpeechRefs(context.Background(), "sk-test", "speech-2.8-hd", map[string]any{ + "prompt": "今天想去上海走一走。", + "format": "mp3", + }) + if err != nil { + t.Fatalf("generateSpeechRefs: %v", err) + } + if gotPath != "/v1/audio/speech" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["model"] != "speech-2.8-hd" || gotBody["input"] != "今天想去上海走一走。" || gotBody["response_format"] != "mp3" { + t.Fatalf("unexpected body: %#v", gotBody) + } + if len(refs) != 1 || refs[0].Kind != "data_url" || refs[0].ContentType != "audio/mpeg" { + t.Fatalf("unexpected refs: %#v", refs) + } +} + +func TestDecodeSpeechGenerationResponseAcceptsNumericErrorCode(t *testing.T) { + _, _, err := decodeSpeechGenerationResponse(http.StatusBadRequest, "application/json", "mp3", []byte(`{ + "error": { + "message": "minimax TTS error: 1004 - auth failed", + "type": "bad_response", + "param": "", + "code": 1004 + } + }`), "speech-2.8-hd") + if err == nil || !strings.Contains(err.Error(), "auth failed") { + t.Fatalf("expected decoded upstream error, got %v", err) + } +} diff --git a/internal/server/repository.go b/internal/server/repository.go index 5b84a93..2c39f3b 100644 --- a/internal/server/repository.go +++ b/internal/server/repository.go @@ -7,6 +7,7 @@ type SessionRepository interface { GetSession(token string) (session, bool, error) DeleteSession(token string) error RotateSession(oldToken string, ttl time.Duration) (session, bool, error) + UpdateSessionGatewayBinding(token string, gatewayUserID int, gatewayAccessToken string) error } type JobRepository interface { diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 7dfd875..7010af3 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -23,16 +23,20 @@ func TestConfigFromEnvDefaultsVideoRouteToViduQ2(t *testing.T) { func TestInferRouteKeyForModelRecognizesViduAsVideo(t *testing.T) { cases := map[string]string{ - "viduq2": "video.image2video", - "vidu2.0": "video.image2video", - "sora-2": "video.image2video", - "runway-gen4": "video.image2video", - "T2V-01": "video.image2video", - "I2V-01": "video.image2video", - "S2V-01": "video.image2video", - "MiniMax-Hailuo-2.3": "video.image2video", - "MiniMax-Hailuo-02": "video.image2video", - "gpt-image-1": "image.text2image", + "viduq2": "video.image2video", + "vidu2.0": "video.image2video", + "sora-2": "video.image2video", + "runway-gen4": "video.image2video", + "T2V-01": "video.image2video", + "I2V-01": "video.image2video", + "S2V-01": "video.image2video", + "MiniMax-Hailuo-2.3": "video.image2video", + "MiniMax-Hailuo-02": "video.image2video", + "doubao-seedance-2-0-260128": "video.image2video", + "seedance-2-0-fast-260128": "video.image2video", + "jimeng_dreamactor_m20_gen_video": "video.image2video", + "jimeng_i2v_first_v30": "video.image2video", + "gpt-image-1": "image.text2image", } for modelID, want := range cases { @@ -56,3 +60,29 @@ func TestInferRouteKeyForModelRecognizesImageEditInputs(t *testing.T) { } } } + +func TestInferRouteKeyForModelRecognizesMusic(t *testing.T) { + cases := []string{ + "music-2.6", + "music-2.6-free", + "music-cover", + "music-cover-free", + } + for _, modelID := range cases { + if got := inferRouteKeyForModel(modelID, nil); got != "music.generate" { + t.Fatalf("inferRouteKeyForModel(%q) = %q, want music.generate", modelID, got) + } + } + if got := inferRouteKeyForModelType("custom-model", "music", nil); got != "music.generate" { + t.Fatalf("inferRouteKeyForModelType(custom-model, music) = %q, want music.generate", got) + } +} + +func TestInferRouteKeyForModelRecognizesSpeech(t *testing.T) { + if got := inferRouteKeyForModel("speech-2.8-hd", nil); got != "speech.synthesize" { + t.Fatalf("inferRouteKeyForModel(speech-2.8-hd) = %q, want speech.synthesize", got) + } + if got := inferRouteKeyForModelType("custom-model", "speech", nil); got != "speech.synthesize" { + t.Fatalf("inferRouteKeyForModelType(custom-model, speech) = %q, want speech.synthesize", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 933b6b6..b936523 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -57,6 +58,7 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/auth/me", s.handleAuthMe) s.mux.HandleFunc("/v1/auth/logout", s.handleAuthLogout) s.mux.HandleFunc("/v1/auth/token/rotate", s.handleAuthTokenRotate) + s.mux.HandleFunc("/v1/auth/gateway/bind", s.handleAuthGatewayBind) s.mux.HandleFunc("/v1/skills", s.handleSkills) s.mux.HandleFunc("/v1/skills/", s.handleSkill) s.mux.HandleFunc("/v1/jobs", s.handleJobs) @@ -69,6 +71,15 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/budget", s.handleBudget) s.mux.HandleFunc("/v1/budget/usage", s.handleBudgetUsage) s.mux.HandleFunc("/v1/budget/limits", s.handleBudgetLimits) + s.mux.HandleFunc("/v1/billing/catalog", s.handleBillingCatalog) + s.mux.HandleFunc("/v1/billing/subscription/plans", s.handleBillingSubscriptionPlans) + s.mux.HandleFunc("/v1/billing/points/packages", s.handleBillingPointPackages) + s.mux.HandleFunc("/v1/billing/subscription", s.handleBillingSubscription) + s.mux.HandleFunc("/v1/billing/credits", s.handleBillingCredits) + s.mux.HandleFunc("/v1/billing/invoices", s.handleBillingInvoices) + s.mux.HandleFunc("/v1/billing/checkout/subscription", s.handleBillingCheckoutSubscription) + s.mux.HandleFunc("/v1/billing/checkout/points", s.handleBillingCheckoutPoints) + s.mux.HandleFunc("/v1/billing/checkout/status", s.handleBillingCheckoutStatus) s.mux.HandleFunc("/v1/projects", s.handleProjects) s.mux.HandleFunc("/v1/projects/", s.handleProject) s.mux.HandleFunc("/v1/models", s.handleModels) @@ -76,6 +87,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/models/routes/overrides", s.handleModelRouteOverrides) s.mux.HandleFunc("/v1/models/routes/overrides/unset", s.handleModelRouteOverrideUnset) s.mux.HandleFunc("/v1/models/infer", s.handleModelsInfer) + s.mux.HandleFunc("/v1/video/generations", s.handleVideoGenerations) + s.mux.HandleFunc("/v1/video/generations/", s.handleVideoGeneration) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { @@ -141,10 +154,17 @@ func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) { if !ok { return } + gatewayAccessTokenMasked := "" + if strings.TrimSpace(current.GatewayAccessToken) != "" { + gatewayAccessTokenMasked = maskSecret(current.GatewayAccessToken) + } writeData(w, http.StatusOK, authSessionView{ - User: current.User, - SessionKey: current.Token, - UpstreamKeyMasked: maskSecret(current.UpstreamKey), + User: current.User, + SessionKey: current.Token, + UpstreamKeyMasked: maskSecret(current.UpstreamKey), + GatewayUserID: current.GatewayUserID, + GatewayAccessTokenMasked: gatewayAccessTokenMasked, + GatewayBound: current.GatewayUserID > 0 && strings.TrimSpace(current.GatewayAccessToken) != "", }) } @@ -186,6 +206,56 @@ func (s *Server) handleAuthTokenRotate(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleAuthGatewayBind(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + var req struct { + GatewayUserID int `json:"gateway_user_id"` + GatewayAccessToken string `json:"gateway_access_token"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.GatewayUserID <= 0 || strings.TrimSpace(req.GatewayAccessToken) == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "gateway_user_id and gateway_access_token are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + self, err := s.newapi.getGatewayUserSelf(ctx, req.GatewayUserID, req.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "GATEWAY_BIND_FAILED", "failed to validate gateway user binding", map[string]any{ + "details": err.Error(), + }) + return + } + if self.ID != req.GatewayUserID { + writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "gateway user id does not match gateway access token", nil) + return + } + if err := s.store.bindGatewaySession(current.Token, req.GatewayUserID, req.GatewayAccessToken); err != nil { + writeInternalError(w, "failed to persist gateway binding", err) + return + } + writeData(w, http.StatusOK, map[string]any{ + "gateway_user_id": self.ID, + "gateway_username": self.Username, + "gateway_display_name": self.DisplayName, + "gateway_email": self.Email, + "gateway_bound": true, + }) +} + func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) @@ -841,6 +911,372 @@ func (s *Server) handleBudgetLimits(w http.ResponseWriter, r *http.Request) { }) } +func billingCatalogSeed() []billingPlan { + return []billingPlan{ + { + ID: "creator-pro-monthly", + ProductType: "subscription", + Badge: "Recommended", + Name: "Creator Plan", + Price: "¥69.99", + Cadence: "/month", + Summary: "For solo creators and small teams that need a stable monthly credit baseline.", + Features: []string{ + "12,000 monthly credits", + "Official skill catalog access", + "Project-level usage visibility", + "Shared web console and CLI auth flow", + }, + CTA: "Choose Creator Plan", + Highlight: true, + }, + { + ID: "credits-top-up-50000", + ProductType: "top_up", + Badge: "Flexible", + Name: "Credits Top-up", + Price: "¥199", + Cadence: "/pack", + Summary: "Add extra spendable credits when video, batch, or campaign work spikes.", + Features: []string{ + "50,000 extra credits", + "Stacks on top of subscriptions", + "Visible in the same billing center", + "Designed for bursty production workloads", + }, + CTA: "Buy credits", + }, + } +} + +func (s *Server) handleBillingCatalog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + writeData(w, http.StatusOK, map[string]any{ + "plans": billingCatalogSeed(), + "capabilities": map[string]any{ + "subscription_status": false, + "credit_balance": false, + "invoices": false, + "checkout": false, + }, + "source": "popiartServer", + "note": "Catalog is product-layer data. Checkout, subscription, credits, and invoices are not wired yet.", + }) +} + +func (s *Server) handleBillingSubscription(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.getGatewaySubscriptionSelf(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription data from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingSubscriptionPlans(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + items, err := s.newapi.listGatewaySubscriptionPlans(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription plans from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) handleBillingPointPackages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + items, err := s.newapi.listGatewayPointPackages(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load point packages from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, map[string]any{"items": items}) +} + +func (s *Server) handleBillingCredits(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.getGatewayPointsMine(ctx, current.GatewayUserID, current.GatewayAccessToken) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load credit data from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingInvoices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + query := r.URL.Query() + params := make(url.Values) + if value := strings.TrimSpace(query.Get("keyword")); value != "" { + params.Set("keyword", value) + } + if value := strings.TrimSpace(query.Get("p")); value != "" { + params.Set("p", value) + } + if value := strings.TrimSpace(query.Get("page_size")); value != "" { + params.Set("page_size", value) + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + subscriptions, err := s.newapi.listGatewaySubscriptionOrders(ctx, current.GatewayUserID, current.GatewayAccessToken, params.Encode()) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load subscription orders from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + points, err := s.newapi.listGatewayPointOrders(ctx, current.GatewayUserID, current.GatewayAccessToken, params.Encode()) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to load point orders from PopiNewAPI", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, map[string]any{ + "subscription_orders": subscriptions, + "point_orders": points, + }) +} + +func (s *Server) handleBillingCheckoutSubscription(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + var req struct { + PlanID int `json:"plan_id"` + Provider string `json:"provider"` + ReturnURL string `json:"return_url"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.PlanID <= 0 || strings.TrimSpace(req.Provider) == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "plan_id and provider are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.createGatewaySubscriptionPayment( + ctx, + current.GatewayUserID, + current.GatewayAccessToken, + strings.TrimSpace(strings.ToLower(req.Provider)), + req.PlanID, + strings.TrimSpace(req.ReturnURL), + ) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_CHECKOUT_FAILED", "failed to create subscription payment", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingCheckoutPoints(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + var req struct { + PackageID int `json:"package_id"` + Provider string `json:"provider"` + ReturnURL string `json:"return_url"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.PackageID <= 0 || strings.TrimSpace(req.Provider) == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "package_id and provider are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + item, err := s.newapi.createGatewayPointsPayment( + ctx, + current.GatewayUserID, + current.GatewayAccessToken, + strings.TrimSpace(strings.ToLower(req.Provider)), + req.PackageID, + strings.TrimSpace(req.ReturnURL), + ) + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_CHECKOUT_FAILED", "failed to create points payment", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + +func (s *Server) handleBillingCheckoutStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + if current.GatewayUserID <= 0 || strings.TrimSpace(current.GatewayAccessToken) == "" { + writeError(w, http.StatusPreconditionFailed, "BILLING_UNBOUND", "gateway billing is not bound for this session", nil) + return + } + kind := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("kind"))) + provider := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("provider"))) + tradeNo := strings.TrimSpace(r.URL.Query().Get("trade_no")) + if kind == "" || provider == "" || tradeNo == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "kind, provider, and trade_no are required", nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + var ( + item gatewayPaymentStatus + err error + ) + switch kind { + case "subscription": + item, err = s.newapi.queryGatewaySubscriptionPayment(ctx, current.GatewayUserID, current.GatewayAccessToken, provider, tradeNo) + case "points": + item, err = s.newapi.queryGatewayPointsPayment(ctx, current.GatewayUserID, current.GatewayAccessToken, provider, tradeNo) + default: + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "kind must be subscription or points", nil) + return + } + if err != nil { + writeError(w, http.StatusBadGateway, "BILLING_FETCH_FAILED", "failed to query payment status", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, item) +} + func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { methodNotAllowed(w) @@ -1077,6 +1513,7 @@ func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) { } var req struct { ModelID string `json:"model_id"` + ModelType string `json:"model_type"` Input map[string]any `json:"input"` ProjectID string `json:"project_id"` Priority string `json:"priority"` @@ -1089,7 +1526,7 @@ func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model_id is required", nil) return } - routeKey := inferRouteKeyForModel(req.ModelID, req.Input) + routeKey := inferRouteKeyForModelType(req.ModelID, req.ModelType, req.Input) record, statusCode, err := s.store.createJob( "", routeKey, @@ -1115,6 +1552,133 @@ func (s *Server) handleModelsInfer(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleVideoGenerations(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/video/generations" { + notFound(w) + return + } + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + + var req map[string]any + if !decodeJSON(w, r, &req) { + return + } + modelID := strings.TrimSpace(stringValue(req["model"])) + if modelID == "" { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "model is required", nil) + return + } + if !isSeedanceVideoModel(modelID) { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "only Seedance video generation is supported on this gateway-compatible route", map[string]any{ + "model": modelID, + }) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + taskID, err := s.newapi.submitSeedanceVideoTask(ctx, current.UpstreamKey, modelID, req) + if err != nil { + writeError(w, http.StatusBadGateway, "UPSTREAM_ERROR", "failed to submit Seedance video task", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusAccepted, map[string]any{ + "task_id": taskID, + "status": "PENDING", + }) +} + +func (s *Server) handleVideoGeneration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if s.newapi == nil || !s.newapi.enabled() { + writeError(w, http.StatusServiceUnavailable, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil) + return + } + taskID := strings.TrimPrefix(r.URL.Path, "/v1/video/generations/") + taskID = strings.Trim(taskID, "/") + if taskID == "" || strings.Contains(taskID, "/") { + notFound(w) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + result, err := s.newapi.fetchVideoTask(ctx, current.UpstreamKey, taskID) + if err != nil { + writeError(w, http.StatusBadGateway, "UPSTREAM_ERROR", "failed to fetch Seedance video task", map[string]any{ + "details": err.Error(), + }) + return + } + writeData(w, http.StatusOK, videoGenerationResponse(result)) +} + +func videoGenerationResponse(result *videoTaskResult) map[string]any { + if result == nil { + return map[string]any{} + } + status := strings.ToUpper(strings.TrimSpace(result.Status)) + switch status { + case "COMPLETED": + status = "SUCCESS" + case "QUEUED": + status = "PENDING" + case "IN_PROGRESS": + status = "RUNNING" + case "": + status = "PENDING" + } + out := map[string]any{ + "task_id": result.TaskID, + "status": status, + } + if strings.TrimSpace(result.Progress) != "" { + out["progress"] = result.Progress + } + metadata := map[string]any{} + if strings.TrimSpace(result.URL) != "" { + out["result_url"] = strings.TrimSpace(result.URL) + metadata["url"] = strings.TrimSpace(result.URL) + } + if strings.TrimSpace(result.LastFrameURL) != "" { + out["last_frame_url"] = strings.TrimSpace(result.LastFrameURL) + metadata["last_frame_url"] = strings.TrimSpace(result.LastFrameURL) + } + if strings.TrimSpace(result.Format) != "" { + metadata["format"] = strings.TrimSpace(result.Format) + } + if len(metadata) > 0 { + out["metadata"] = metadata + } + if strings.TrimSpace(result.ErrorCode) != "" || strings.TrimSpace(result.ErrorReason) != "" { + out["error"] = map[string]any{ + "code": strings.TrimSpace(result.ErrorCode), + "message": strings.TrimSpace(result.ErrorReason), + } + } + return out +} + func (s *Server) dispatchJob(record *job) { if record == nil { return @@ -1127,6 +1691,10 @@ func (s *Server) dispatchJob(record *job) { s.executeImageToImageJob(record) case "video.image2video": s.executeImageToVideoJob(record) + case "music.generate": + s.executeMusicGenerationJob(record) + case "speech.synthesize": + s.executeSpeechSynthesisJob(record) default: s.executeUnsupportedSkill(record) } @@ -1287,7 +1855,21 @@ func (s *Server) executeImageToVideoJob(record *job) { var refsForGateway []imageEditReference var ref imageEditReference var err error - if useMiniMaxVideoGenerations(modelID) { + if useSeedanceVideoGenerations(modelID) { + // Seedance routes through unified JSON video generations and can accept + // text-only, image, video, and audio references directly from input. + } else if useJimengVideoGenerations(modelID) { + if err := validateJimengVideoInput(modelID, input); err != nil { + if repoErr := s.store.failJob(record.JobID, "VALIDATION_ERROR", err.Error(), map[string]any{ + "skill_id": record.SkillID, + "route_key": record.RouteKey, + "model_id": modelID, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + } else if useMiniMaxVideoGenerations(modelID) { if hasVideoReferenceInput(input) { refsForMiniMax, err = s.resolveVideoReferences(submitCtx, record, input) if err != nil { @@ -1325,7 +1907,11 @@ func (s *Server) executeImageToVideoJob(record *job) { } var upstreamTaskID string - if useMiniMaxVideoGenerations(modelID) { + if useSeedanceVideoGenerations(modelID) { + upstreamTaskID, err = s.newapi.submitSeedanceVideoTask(submitCtx, record.UpstreamKey, modelID, input) + } else if useJimengVideoGenerations(modelID) { + upstreamTaskID, err = s.newapi.submitJimengVideoTask(submitCtx, record.UpstreamKey, modelID, input) + } else if useMiniMaxVideoGenerations(modelID) { upstreamTaskID, err = s.newapi.submitMiniMaxVideoTask(submitCtx, record.UpstreamKey, modelID, input, refsForMiniMax) } else if len(refsForGateway) > 1 { upstreamTaskID, err = s.newapi.submitImageToVideoTaskWithReferences(submitCtx, record.UpstreamKey, modelID, input, refsForGateway) @@ -1410,7 +1996,27 @@ func hasVideoReferenceInput(input map[string]any) bool { input["image"], input["image_url"], input["reference_image_url"], - )) != "" || len(extractStringValues(input["images"])) > 0 + )) != "" || len(extractStringValues(input["images"])) > 0 || len(extractStringValues(input["videos"])) > 0 +} + +func validateJimengVideoInput(modelID string, input map[string]any) error { + images := extractStringValues(input["images"]) + videos := extractStringValues(input["videos"]) + action := strings.TrimSpace(stringValue(nestedMetadataValue(input, "action"))) + if action == "" && useJimengDreamActorModel(modelID) { + action = "actionGenerate" + } + switch action { + case "actionGenerate": + if len(images) != 1 || len(videos) == 0 || strings.TrimSpace(videos[0]) == "" { + return fmt.Errorf("jimeng actionGenerate requires exactly one image and videos[0]") + } + default: + // Let PopiNewAPI / upstream enforce model-specific image counts for + // text, first-frame, first-tail, and recamera Jimeng models. + return nil + } + return nil } func (s *Server) executeUnsupportedSkill(record *job) { @@ -1427,6 +2033,94 @@ func (s *Server) executeUnsupportedSkill(record *job) { } } +func (s *Server) executeMusicGenerationJob(record *job) { + if _, _, err := s.store.startJob(record.JobID); err != nil { + log.Printf("popiartServer: start job %s failed: %v", record.JobID, err) + } + + if s.newapi == nil || !s.newapi.enabled() { + if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err) + } + return + } + + modelID := strings.TrimSpace(record.ModelID) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + refs, usage, err := s.newapi.generateMusicRefs(ctx, record.UpstreamKey, modelID, record.Input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{ + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + refs, err = s.persistResultRefs(ctx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated audio", map[string]any{ + "details": err.Error(), + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { + log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) + } +} + +func (s *Server) executeSpeechSynthesisJob(record *job) { + if _, _, err := s.store.startJob(record.JobID); err != nil { + log.Printf("popiartServer: start job %s failed: %v", record.JobID, err) + } + + if s.newapi == nil || !s.newapi.enabled() { + if err := s.store.failJob(record.JobID, "NEWAPI_NOT_CONFIGURED", "PopiNewAPI is not configured for popiartServer", nil); err != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, err) + } + return + } + + modelID := strings.TrimSpace(record.ModelID) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + refs, usage, err := s.newapi.generateSpeechRefs(ctx, record.UpstreamKey, modelID, record.Input) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{ + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + refs, err = s.persistResultRefs(ctx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated speech", map[string]any{ + "details": err.Error(), + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { + log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) + } +} + func (s *Server) resolveModelID(routeKey, projectID string) string { routes, err := s.store.routesForProject(projectID) if err == nil { @@ -1443,6 +2137,22 @@ func (s *Server) resolveModelID(routeKey, projectID string) string { } func inferRouteKeyForModel(modelID string, input map[string]any) string { + return inferRouteKeyForModelType(modelID, "", input) +} + +func inferRouteKeyForModelType(modelID, modelType string, input map[string]any) string { + switch strings.ToLower(strings.TrimSpace(modelType)) { + case "music", "audio.music": + return "music.generate" + case "speech", "audio.speech", "tts": + return "speech.synthesize" + } + if isMusicModelID(modelID) { + return "music.generate" + } + if isSpeechModelID(modelID) { + return "speech.synthesize" + } if modelType, _ := classifyModelIDFallback(modelID); modelType == "video" { return "video.image2video" } @@ -1459,6 +2169,25 @@ func inferRouteKeyForModel(modelID string, input map[string]any) string { return "image.text2image" } +func isSpeechModelID(modelID string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelID)) + return strings.HasPrefix(normalized, "speech-") || strings.Contains(normalized, "tts") +} + +func isMusicModelID(modelID string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelID)) + return normalized == "music-2.6" || + normalized == "music-2.6-free" || + normalized == "music-cover" || + normalized == "music-cover-free" +} + +func isSeedanceVideoModel(modelID string) bool { + normalized := strings.ToLower(strings.TrimSpace(modelID)) + return strings.Contains(normalized, "doubao-seedance") || + strings.Contains(normalized, "seedance") +} + func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) { if record == nil { return nil, fmt.Errorf("job record is required") @@ -1724,11 +2453,15 @@ func buildImageToVideoInput(record *job) map[string]any { if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { parts = append(parts, "aspect ratio "+aspectRatio) } - if len(parts) == 0 { + if len(parts) == 0 && !isActionGenerateInput(input) { parts = append(parts, "Generate a short polished cinematic motion clip that preserves the reference image subject identity.") } - input["prompt"] = strings.Join(parts, ", ") + if len(parts) > 0 { + input["prompt"] = strings.Join(parts, ", ") + } else { + delete(input, "prompt") + } delete(input, "motion_prompt") delete(input, "scene_prompt") return input @@ -1802,6 +2535,21 @@ func ensureInputMetadataAction(input map[string]any, action string) { } } +func isActionGenerateInput(input map[string]any) bool { + return strings.TrimSpace(stringValue(nestedMetadataValue(input, "action"), input["action"])) == "actionGenerate" +} + +func nestedMetadataValue(input map[string]any, key string) any { + if input == nil { + return nil + } + metadata, ok := input["metadata"].(map[string]any) + if !ok || metadata == nil { + return nil + } + return metadata[key] +} + func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string { parts := []string{ "PopiStudio Alice as the same fixed main protagonist from the canonical Alice reference image", diff --git a/internal/server/sqlite_repo.go b/internal/server/sqlite_repo.go index a50c2bd..879df8a 100644 --- a/internal/server/sqlite_repo.go +++ b/internal/server/sqlite_repo.go @@ -56,6 +56,8 @@ func (r *sqliteRepository) migrate() error { user_json TEXT NOT NULL, token_enc BLOB NOT NULL, token_masked TEXT NOT NULL, + gateway_user_id INTEGER, + gateway_access_token_enc BLOB, created_at TEXT NOT NULL, expires_at TEXT NOT NULL );`, @@ -154,6 +156,14 @@ func (r *sqliteRepository) migrate() error { return fmt.Errorf("upgrade sqlite jobs schema: %w", err) } } + for _, stmt := range []string{ + `ALTER TABLE sessions ADD COLUMN gateway_user_id INTEGER;`, + `ALTER TABLE sessions ADD COLUMN gateway_access_token_enc BLOB;`, + } { + if _, err := r.db.Exec(stmt); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return fmt.Errorf("upgrade sqlite sessions schema: %w", err) + } + } return nil } @@ -194,17 +204,27 @@ func (r *sqliteRepository) CreateSession(upstreamKey string, u user, ttl time.Du func (r *sqliteRepository) GetSession(token string) (session, bool, error) { row := r.db.QueryRow( - `SELECT user_id, user_json, token_enc, created_at, expires_at + `SELECT user_id, user_json, token_enc, gateway_user_id, gateway_access_token_enc, created_at, expires_at FROM sessions WHERE session_id = ?`, token, ) var ( - current session - userJSON string - tokenEnc []byte - createdAt, expiresAt string + current session + userJSON string + tokenEnc []byte + gatewayUserID sql.NullInt64 + gatewayAccessTokenEnc []byte + createdAt, expiresAt string ) - if err := row.Scan(¤t.UserID, &userJSON, &tokenEnc, &createdAt, &expiresAt); errors.Is(err, sql.ErrNoRows) { + if err := row.Scan( + ¤t.UserID, + &userJSON, + &tokenEnc, + &gatewayUserID, + &gatewayAccessTokenEnc, + &createdAt, + &expiresAt, + ); errors.Is(err, sql.ErrNoRows) { return session{}, false, nil } else if err != nil { return session{}, false, fmt.Errorf("scan session: %w", err) @@ -217,6 +237,16 @@ func (r *sqliteRepository) GetSession(token string) (session, bool, error) { if err != nil { return session{}, false, err } + if gatewayUserID.Valid { + current.GatewayUserID = int(gatewayUserID.Int64) + } + if len(gatewayAccessTokenEnc) > 0 { + gatewayAccessToken, err := r.decryptSecret(gatewayAccessTokenEnc) + if err != nil { + return session{}, false, err + } + current.GatewayAccessToken = gatewayAccessToken + } current.Token = token current.UpstreamKey = upstreamKey current.CreatedAt = parseRFC3339(createdAt) @@ -248,9 +278,41 @@ func (r *sqliteRepository) RotateSession(oldToken string, ttl time.Duration) (se if err != nil { return session{}, false, err } + if current.GatewayUserID > 0 && strings.TrimSpace(current.GatewayAccessToken) != "" { + if err := r.UpdateSessionGatewayBinding(next.Token, current.GatewayUserID, current.GatewayAccessToken); err != nil { + return session{}, false, err + } + next.GatewayUserID = current.GatewayUserID + next.GatewayAccessToken = current.GatewayAccessToken + } return next, true, nil } +func (r *sqliteRepository) UpdateSessionGatewayBinding(token string, gatewayUserID int, gatewayAccessToken string) error { + if strings.TrimSpace(token) == "" { + return errors.New("session token is required") + } + if gatewayUserID <= 0 { + return errors.New("gateway user id is required") + } + if strings.TrimSpace(gatewayAccessToken) == "" { + return errors.New("gateway access token is required") + } + enc, err := r.encryptSecret(gatewayAccessToken) + if err != nil { + return err + } + if _, err := r.db.Exec( + `UPDATE sessions SET gateway_user_id = ?, gateway_access_token_enc = ? WHERE session_id = ?`, + gatewayUserID, + enc, + token, + ); err != nil { + return fmt.Errorf("update session gateway binding: %w", err) + } + return nil +} + func (r *sqliteRepository) CreateJob(record *job) (*job, int, error) { if record == nil { return nil, 0, errors.New("job record is required") diff --git a/internal/server/store.go b/internal/server/store.go index 76e5d1a..861d237 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -152,6 +152,10 @@ func (s *store) rotateSession(oldToken string) (string, user, bool, error) { return current.Token, current.User, true, nil } +func (s *store) bindGatewaySession(token string, gatewayUserID int, gatewayAccessToken string) error { + return s.sessions.UpdateSessionGatewayBinding(token, gatewayUserID, gatewayAccessToken) +} + func (s *store) listSkills(tag, search string, limit, offset int) ([]skill, int) { filtered := make([]skill, 0, len(s.skills)) for _, item := range s.skills { diff --git a/internal/server/types.go b/internal/server/types.go index 5853213..839f672 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -12,18 +12,23 @@ type user struct { } type session struct { - Token string - UserID string - UpstreamKey string - User user - CreatedAt time.Time - ExpiresAt time.Time + Token string + UserID string + UpstreamKey string + GatewayUserID int + GatewayAccessToken string + User user + CreatedAt time.Time + ExpiresAt time.Time } type authSessionView struct { - User user `json:"user"` - SessionKey string `json:"session_key,omitempty"` - UpstreamKeyMasked string `json:"upstream_key_masked,omitempty"` + User user `json:"user"` + SessionKey string `json:"session_key,omitempty"` + UpstreamKeyMasked string `json:"upstream_key_masked,omitempty"` + GatewayUserID int `json:"gateway_user_id,omitempty"` + GatewayAccessTokenMasked string `json:"gateway_access_token_masked,omitempty"` + GatewayBound bool `json:"gateway_bound"` } type skill struct { @@ -138,3 +143,16 @@ type model struct { Capabilities []string `json:"capabilities"` Pricing map[string]any `json:"pricing"` } + +type billingPlan struct { + ID string `json:"id"` + ProductType string `json:"product_type"` + Badge string `json:"badge"` + Name string `json:"name"` + Price string `json:"price"` + Cadence string `json:"cadence"` + Summary string `json:"summary"` + Features []string `json:"features"` + CTA string `json:"cta"` + Highlight bool `json:"highlight,omitempty"` +} diff --git a/internal/server/video_test.go b/internal/server/video_test.go index 84471ae..571c6da 100644 --- a/internal/server/video_test.go +++ b/internal/server/video_test.go @@ -288,6 +288,243 @@ func TestSubmitMiniMaxVideoTaskKeepsS2VImagesArray(t *testing.T) { } } +func TestSubmitJimengVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + imageBase64 := base64.StdEncoding.EncodeToString([]byte("fake-jimeng-image-body")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected application/json content type, got %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_action_123","status":"in_queue"}}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitJimengVideoTask(context.Background(), "sk-test", "jimeng_dreamactor_m20_gen_video", map[string]any{ + "images": []any{"data:image/jpeg;base64," + imageBase64}, + "videos": []any{"https://example.com/source-action.mp4"}, + "metadata": map[string]any{ + "cut_result_first_second_switch": true, + }, + }) + if err != nil { + t.Fatalf("submitJimengVideoTask: %v", err) + } + if taskID != "task_jimeng_action_123" { + t.Fatalf("unexpected task id: %q", taskID) + } + if gotPath != "/v1/video/generations" { + t.Fatalf("expected /v1/video/generations, got %q", gotPath) + } + if gotBody["model"] != "jimeng_dreamactor_m20_gen_video" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + images, ok := gotBody["images"].([]any) + if !ok || len(images) != 1 || images[0] != imageBase64 { + t.Fatalf("expected pure base64 image payload, got %#v", gotBody["images"]) + } + videos, ok := gotBody["videos"].([]any) + if !ok || len(videos) != 1 || videos[0] != "https://example.com/source-action.mp4" { + t.Fatalf("unexpected videos payload: %#v", gotBody["videos"]) + } + metadata, ok := gotBody["metadata"].(map[string]any) + if !ok || metadata["action"] != "actionGenerate" || metadata["cut_result_first_second_switch"] != true { + t.Fatalf("unexpected metadata: %#v", gotBody["metadata"]) + } +} + +func TestBuildJimengVideoGenerationPayloadMapsDurationToFrames(t *testing.T) { + payload, err := buildJimengVideoGenerationPayload("jimeng_t2v_v30", map[string]any{ + "prompt": "cinematic cavalry charge", + "duration": 10, + "aspect_ratio": "16:9", + "seed": -1, + }) + if err != nil { + t.Fatalf("buildJimengVideoGenerationPayload: %v", err) + } + if payload["duration"] != 10 { + t.Fatalf("expected duration 10, got %#v", payload["duration"]) + } + metadata := payload["metadata"].(map[string]any) + if metadata["frames"] != 241 { + t.Fatalf("expected frames 241, got %#v", metadata["frames"]) + } + if metadata["aspect_ratio"] != "16:9" || metadata["seed"] != -1 { + t.Fatalf("unexpected metadata: %#v", metadata) + } +} + +func TestSubmitSeedanceVideoTaskUsesVideoGenerationsEndpoint(t *testing.T) { + var gotPath string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task_seedance_123","status":"submitted"}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitSeedanceVideoTask(context.Background(), "sk-test", "doubao-seedance-2-0-260128", map[string]any{ + "prompt": "keep the motion style consistent", + "size": "720p", + "duration": 5, + "images": []any{"https://example.com/a.jpg", "https://example.com/b.jpg"}, + "videos": []any{"https://example.com/ref.mp4"}, + "audios": []any{"https://example.com/ref.mp3"}, + "ratio": "16:9", + "return_last_frame": true, + }) + if err != nil { + t.Fatalf("submitSeedanceVideoTask: %v", err) + } + if taskID != "task_seedance_123" { + t.Fatalf("unexpected task id: %q", taskID) + } + if gotPath != "/v1/video/generations" { + t.Fatalf("expected /v1/video/generations, got %q", gotPath) + } + if gotBody["model"] != "doubao-seedance-2-0-260128" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + metadata := gotBody["metadata"].(map[string]any) + if metadata["ratio"] != "16:9" || metadata["return_last_frame"] != true { + t.Fatalf("unexpected metadata: %#v", metadata) + } +} + +func TestHandleVideoGenerationsRelaysSeedanceRequest(t *testing.T) { + var gotBody map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/video/generations" { + t.Fatalf("unexpected upstream request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-test-upstream" { + t.Fatalf("unexpected upstream auth: %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode upstream body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_seedance_direct_123","status":"PENDING"}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + token, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(`{"model":"doubao-seedance-2-0-260128","prompt":"ping","metadata":{"ratio":"16:9"}}`)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d body=%s", rec.Code, rec.Body.String()) + } + if gotBody["model"] != "doubao-seedance-2-0-260128" || gotBody["prompt"] != "ping" { + t.Fatalf("unexpected upstream body: %#v", gotBody) + } + var env struct { + OK bool `json:"ok"` + Data struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode response: %v", err) + } + if !env.OK || env.Data.TaskID != "task_seedance_direct_123" || env.Data.Status != "PENDING" { + t.Fatalf("unexpected response: %#v", env) + } +} + +func TestHandleVideoGenerationFetchSurfacesLastFrameURL(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/video/generations/task_seedance_direct_123" { + t.Fatalf("unexpected upstream request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-test-upstream" { + t.Fatalf("unexpected upstream auth: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_seedance_direct_123","status":"SUCCESS","metadata":{"url":"https://cdn.example.com/video.mp4","last_frame_url":"https://cdn.example.com/last-frame.png"}}}`)) + })) + defer upstream.Close() + + server, err := NewWithConfig(Config{ + NewAPIBaseURL: upstream.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + }) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + token, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + req := httptest.NewRequest(http.MethodGet, "/v1/video/generations/task_seedance_direct_123", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var env struct { + OK bool `json:"ok"` + Data struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + ResultURL string `json:"result_url"` + LastFrameURL string `json:"last_frame_url"` + Metadata map[string]any `json:"metadata"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { + t.Fatalf("decode response: %v", err) + } + if !env.OK || env.Data.Status != "SUCCESS" || env.Data.ResultURL != "https://cdn.example.com/video.mp4" || env.Data.LastFrameURL != "https://cdn.example.com/last-frame.png" { + t.Fatalf("unexpected response: %#v", env) + } + if env.Data.Metadata["last_frame_url"] != "https://cdn.example.com/last-frame.png" { + t.Fatalf("unexpected metadata: %#v", env.Data.Metadata) + } +} + func TestResolveVideoReferencesSupportsImagesArray(t *testing.T) { cfg := Config{ SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), @@ -585,6 +822,61 @@ func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) { } } +func TestFetchVideoTaskPrefersGenericGenerationStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/videos/task_generic_wins": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task_generic_wins","status":"in_progress","progress":50}`)) + case "/v1/video/generations/task_generic_wins": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":0,"data":{"id":"task_generic_wins","status":"SUCCESS","result_url":"http://example.com/done.mp4","progress":100}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + result, err := client.fetchVideoTask(context.Background(), "sk-test", "task_generic_wins") + if err != nil { + t.Fatalf("fetchVideoTask: %v", err) + } + if result.Status != "completed" { + t.Fatalf("expected generic SUCCESS to win, got %q", result.Status) + } + if result.URL != "http://example.com/done.mp4" { + t.Fatalf("expected result url to round-trip, got %q", result.URL) + } +} + +func TestFetchVideoTaskMapsJimengDoneNonSuccessCodeToFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/videos/task_jimeng_failed": + http.NotFound(w, r) + case "/v1/video/generations/task_jimeng_failed": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_failed","status":"done","code":40001,"fail_reason":"Image Decode Error"}}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + result, err := client.fetchVideoTask(context.Background(), "sk-test", "task_jimeng_failed") + if err != nil { + t.Fatalf("fetchVideoTask: %v", err) + } + if result.Status != "failed" { + t.Fatalf("expected failed status, got %q", result.Status) + } + if result.ErrorReason != "Image Decode Error" { + t.Fatalf("expected fail reason to round-trip, got %q", result.ErrorReason) + } +} + func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) { refBytes := tinyPNG(t) videoBytes := []byte("not-a-real-mp4-but-good-enough-for-streaming") @@ -746,6 +1038,107 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) { } } +func TestExecuteJimengActionTransferJobUsesImagesAndVideos(t *testing.T) { + videoBytes := []byte("jimeng-mp4") + var gotBody map[string]any + + var outputURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/video/generations": + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode Jimeng request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_exec_123","status":"in_queue"}}`)) + case "/v1/videos/task_jimeng_exec_123": + http.NotFound(w, r) + case "/v1/video/generations/task_jimeng_exec_123": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":"success","data":{"task_id":"task_jimeng_exec_123","status":"done","code":10000,"result_url":"` + outputURL + `","progress":"100%"}}`)) + case "/jimeng-output.mp4": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(videoBytes) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + outputURL = srv.URL + "/jimeng-output.mp4" + + cfg := Config{ + NewAPIBaseURL: srv.URL, + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + } + server, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + + token, _, ok, err := server.store.createSession("sk-test-upstream") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + current, exists, err := server.store.session(token) + if err != nil { + t.Fatalf("load session: %v", err) + } + if !exists { + t.Fatal("expected stored session") + } + + record, _, err := server.store.createJob( + "", + "video.image2video", + "jimeng_dreamactor_m20_gen_video", + routeExecMode("video.image2video"), + map[string]any{ + "images": []any{"https://example.com/face.jpg"}, + "videos": []any{"https://example.com/action.mp4"}, + "metadata": map[string]any{ + "action": "actionGenerate", + }, + }, + "", + "normal", + "", + current, + ) + if err != nil { + t.Fatalf("createJob: %v", err) + } + + server.executeImageToVideoJob(record) + + if gotBody["model"] != "jimeng_dreamactor_m20_gen_video" { + t.Fatalf("unexpected Jimeng model: %#v", gotBody["model"]) + } + images := gotBody["images"].([]any) + videos := gotBody["videos"].([]any) + if images[0] != "https://example.com/face.jpg" || videos[0] != "https://example.com/action.mp4" { + t.Fatalf("unexpected Jimeng media payload: images=%#v videos=%#v", gotBody["images"], gotBody["videos"]) + } + + done, exists, err := server.store.getJob(current.User.ID, record.JobID) + if err != nil { + t.Fatalf("getJob: %v", err) + } + if !exists || done.Status != "done" { + t.Fatalf("expected completed Jimeng job, exists=%v record=%#v", exists, done) + } + if done.NewAPITaskID != "task_jimeng_exec_123" { + t.Fatalf("expected Jimeng task id, got %q", done.NewAPITaskID) + } + if len(done.ArtifactIDs) != 1 { + t.Fatalf("expected one Jimeng artifact, got %#v", done.ArtifactIDs) + } +} + func TestResolveVideoSizeUsesViduResolutionLabels(t *testing.T) { if got := resolveVideoSize("viduq2", map[string]any{"aspect_ratio": "16:9"}, imageEditReference{}); got != "720p" { t.Fatalf("expected vidu 16:9 to map to 720p, got %q", got) diff --git a/web/app/[locale]/billing/page.tsx b/web/app/[locale]/billing/page.tsx new file mode 100644 index 0000000..a9b3f8f --- /dev/null +++ b/web/app/[locale]/billing/page.tsx @@ -0,0 +1,385 @@ +import Link from "next/link"; +import { BillingAutoRefresh, BillingRefreshListener } from "@/components/billing-refresh-listener"; +import { BillingPageState } from "@/components/billing-page-state"; +import { + type BillingCredits, + type BillingInvoices, + type BillingSubscription, + PopiartApiError, + getBillingCredits, + getBillingInvoices, + getBillingSubscription, + getViewerSession, +} from "@/lib/popiart-api"; +import { type Locale } from "@/lib/site-content"; + +export const dynamic = "force-dynamic"; + +function formatNumber(locale: Locale, value: number) { + return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(value); +} + +function formatError(error: unknown) { + if (error instanceof PopiartApiError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export default async function BillingPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ kind?: string; status?: string; page?: string }>; +}) { + const { locale } = await params; + const { kind, status, page } = await searchParams; + const typedLocale = locale as Locale; + const isZh = typedLocale === "zh"; + const session = await getViewerSession(); + + const title = isZh ? "账单中心" : "Billing"; + const subtitle = isZh + ? "查看当前订阅、积分钱包和订单历史。" + : "Review your active subscription, point wallets, and order history."; + const loginCta = isZh ? "去登录" : "Sign in"; + const consoleCta = isZh ? "前往控制台" : "Open console"; + const unboundTitle = isZh ? "先绑定网关用户" : "Bind a gateway user first"; + const unboundBody = isZh + ? "当前 session 还没有绑定网关用户态,先去控制台绑定后才能读取真实账单与订单。" + : "This session is not bound to a gateway user yet. Bind it in the console first to read live billing and order data."; + const subscriptionTitle = isZh ? "当前订阅" : "Current subscription"; + const creditsTitle = isZh ? "积分钱包" : "Point wallets"; + const invoicesTitle = isZh ? "订单历史" : "Order history"; + const noSubscription = isZh ? "当前没有有效订阅。" : "No active subscription."; + const noCredits = isZh ? "当前没有积分钱包记录。" : "No point wallets."; + const noOrders = isZh ? "当前没有订单记录。" : "No orders yet."; + const kindFilter = kind === "subscription" || kind === "points" ? kind : "all"; + const statusFilter = typeof status === "string" && status.trim() ? status.trim() : "all"; + const currentPage = Number.isFinite(Number(page)) && Number(page) > 0 ? Number(page) : 1; + const pageSize = 8; + const kindLabel = isZh ? "类型" : "Kind"; + const statusLabel = isZh ? "状态" : "Status"; + const allLabel = isZh ? "全部" : "All"; + const subscriptionsLabel = isZh ? "订阅订单" : "Subscription orders"; + const pointsLabel = isZh ? "积分包订单" : "Point orders"; + const prevLabel = isZh ? "上一页" : "Previous"; + const nextLabel = isZh ? "下一页" : "Next"; + const pageLabel = isZh ? "页码" : "Page"; + + if (!session) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+
+ +
+
+

{isZh ? "登录后查看真实账单" : "Sign in to view live billing"}

+
+
+ + {loginCta} + +
+
+
+ ); + } + + if (!session.gateway_bound) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+
+ +
+
+

{unboundTitle}

+

{unboundBody}

+
+
+ + {consoleCta} + +
+
+
+ ); + } + + let subscription: BillingSubscription | null = null; + let credits: BillingCredits | null = null; + let invoices: BillingInvoices | null = null; + const errors: string[] = []; + + const [subscriptionResult, creditsResult, invoicesResult] = await Promise.allSettled([ + getBillingSubscription(), + getBillingCredits(), + getBillingInvoices(), + ]); + + if (subscriptionResult.status === "fulfilled") { + subscription = subscriptionResult.value; + } else { + errors.push(formatError(subscriptionResult.reason)); + } + if (creditsResult.status === "fulfilled") { + credits = creditsResult.value; + } else { + errors.push(formatError(creditsResult.reason)); + } + if (invoicesResult.status === "fulfilled") { + invoices = invoicesResult.value; + } else { + errors.push(formatError(invoicesResult.reason)); + } + + type BillingOrderItem = Record & { __kind: "subscription" | "points" }; + + const allOrders: BillingOrderItem[] = [ + ...((invoices?.subscription_orders?.items || []).map((item) => ({ ...item, __kind: "subscription" as const }))), + ...((invoices?.point_orders?.items || []).map((item) => ({ ...item, __kind: "points" as const }))), + ]; + const filteredOrders = allOrders.filter((item) => { + if (kindFilter !== "all" && item.__kind !== kindFilter) { + return false; + } + if (statusFilter !== "all" && String(item.status || "") !== statusFilter) { + return false; + } + return true; + }); + const totalPages = Math.max(1, Math.ceil(filteredOrders.length / pageSize)); + const safePage = Math.min(currentPage, totalPages); + const pagedOrders = filteredOrders.slice((safePage - 1) * pageSize, safePage * pageSize); + const availableStatuses = Array.from( + new Set( + allOrders + .map((item) => String(item.status || "").trim()) + .filter(Boolean), + ), + ); + const hasPendingOrders = allOrders.some((item) => { + const currentStatus = String(item.status || "").trim(); + return currentStatus !== "" && !["success", "SUCCESS", "TRADE_SUCCESS", "failed", "FAILED", "closed", "CLOSED", "TRADE_CLOSED"].includes(currentStatus); + }); + + function buildBillingHref(nextKind: string, nextStatus: string, nextPage: number) { + const query = new URLSearchParams(); + if (nextKind !== "all") { + query.set("kind", nextKind); + } + if (nextStatus !== "all") { + query.set("status", nextStatus); + } + if (nextPage > 1) { + query.set("page", String(nextPage)); + } + const serialized = query.toString(); + return serialized ? `/${locale}/billing?${serialized}` : `/${locale}/billing`; + } + + return ( +
+ + + + +
+
+

{title}

+

{subtitle}

+
+ {errors.length > 0 ? ( +
+ {errors.join(" | ")} +
+ ) : null} +
+ +
+
+
+

{subscriptionTitle}

+
+ {subscription && subscription.subscriptions.length > 0 ? ( +
+ {subscription.subscriptions.map((item, index) => ( +
+
+ {item.subscription?.member_level || "subscription"} + {item.subscription?.status || "-"} +
+
+ {isZh ? "可用积分" : "Available points"} + + {formatNumber( + typedLocale, + subscription.subscription_points[String(item.subscription?.id)]?.available_points || 0, + )} + +
+
+ ))} +
+ ) : ( +

{noSubscription}

+ )} +
+ +
+
+

{creditsTitle}

+
+ {credits && credits.wallets.length > 0 ? ( +
+
+
+ {isZh ? "当前余额" : "Balance"} + {formatNumber(typedLocale, credits.balance)} +
+
+ {isZh ? "钱包数量" : "Wallet count"} + {formatNumber(typedLocale, credits.wallets.length)} +
+
+ {credits.wallets.map((wallet) => ( +
+
+ {wallet.source_type} + {isZh ? "可用积分" : "Available points"}: {formatNumber(typedLocale, wallet.points)} +
+
+ {isZh ? "总积分" : "Total points"} + {formatNumber(typedLocale, wallet.points_total)} +
+
+ ))} +
+ ) : ( +

{noCredits}

+ )} +
+
+ +
+
+

{invoicesTitle}

+
+ {allOrders.length > 0 ? ( +
+
+
+ {kindLabel} +
+ + {allLabel} + + + {subscriptionsLabel} + + + {pointsLabel} + +
+
+
+ {statusLabel} +
+ + {allLabel} + + {availableStatuses.map((itemStatus) => ( + + {itemStatus} + + ))} +
+
+
+ + {pagedOrders.map((item, index) => ( +
+ +
+ {String(item.plan_title || item.package_name || item.trade_no || "order")} + {String(item.status || "-")} +
+
+ {String(item.money || "-")} {String(item.currency || "")} + {String(item.payment_method || "-")} +
+
+
+
+
+
+ {isZh ? "交易号" : "Trade no"} + {String(item.trade_no || "-")} +
+
+ {isZh ? "退款状态" : "Refund status"} + {String(item.refund_status || "-")} +
+
+
+
+ {isZh ? "完成时间" : "Completed at"} + {String(item.complete_time || "-")} +
+
+ {item.__kind === "subscription" ? (isZh ? "订阅 ID" : "Subscription ID") : (isZh ? "到账积分" : "Delivered points")} + {String(item.__kind === "subscription" ? item.subscription_id : item.points_amount || "-")} +
+
+
+
+
+ ))} +
+ + {prevLabel} + + + {pageLabel}: {safePage} / {totalPages} + + = totalPages} + className={`button button-light button-small ${safePage >= totalPages ? "button-disabled" : ""}`} + href={buildBillingHref(kindFilter, statusFilter, Math.min(totalPages, safePage + 1))} + > + {nextLabel} + +
+
+ ) : ( +

{noOrders}

+ )} +
+
+ ); +} diff --git a/web/app/[locale]/billing/success/page.tsx b/web/app/[locale]/billing/success/page.tsx new file mode 100644 index 0000000..66bec96 --- /dev/null +++ b/web/app/[locale]/billing/success/page.tsx @@ -0,0 +1,106 @@ +import Link from "next/link"; +import { type BillingInvoices, getBillingInvoices, getViewerSession } from "@/lib/popiart-api"; +import { type Locale } from "@/lib/site-content"; + +export default async function BillingSuccessPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ kind?: string; provider?: string; trade_no?: string }>; +}) { + const { locale } = await params; + const { kind, provider, trade_no: tradeNo } = await searchParams; + const typedLocale = locale as Locale; + const isZh = typedLocale === "zh"; + const session = await getViewerSession(); + let invoices: BillingInvoices | null = null; + + if (session && tradeNo) { + try { + invoices = await getBillingInvoices({ keyword: tradeNo, page: 1, pageSize: 10 }); + } catch { + invoices = null; + } + } + + const matchedOrder = + (invoices?.subscription_orders.items || []).find((item) => String(item.trade_no || "") === String(tradeNo || "")) || + (invoices?.point_orders.items || []).find((item) => String(item.trade_no || "") === String(tradeNo || "")); + + return ( +
+
+
+
{isZh ? "PAYMENT" : "PAYMENT"}
+

{isZh ? "支付已完成" : "Payment completed"}

+

+ {isZh + ? "订单状态已切换为成功。你可以继续回到控制台或账单中心查看最新订阅、积分和订单历史。" + : "The order has completed successfully. Continue to the console or billing center to review the latest subscription, credits, and order history."} +

+
+
+ +
+
+

{isZh ? "支付结果" : "Payment result"}

+
+
+
+
+ {isZh ? "订单类型" : "Order kind"} + {kind || "-"} +
+
+ {isZh ? "支付渠道" : "Provider"} + {provider || "-"} +
+
+
+
+ {isZh ? "交易号" : "Trade no"} + {tradeNo || "-"} +
+
+ {isZh ? "状态" : "Status"} + {isZh ? "成功" : "Success"} +
+
+ {matchedOrder ? ( + <> +
+
+ {isZh ? "订单标题" : "Order title"} + {String(matchedOrder.plan_title || matchedOrder.package_name || "-")} +
+
+ {isZh ? "金额" : "Amount"} + {String(matchedOrder.money || "-")} {String(matchedOrder.currency || "")} +
+
+
+
+ {isZh ? "支付方式" : "Payment method"} + {String(matchedOrder.payment_method || "-")} +
+
+ {isZh ? "完成时间" : "Completed at"} + {String(matchedOrder.complete_time || "-")} +
+
+ + ) : null} +
+
+ + {isZh ? "查看账单中心" : "Open billing"} + + + {isZh ? "前往控制台" : "Open console"} + +
+
+
+ ); +} diff --git a/web/app/[locale]/console/page.tsx b/web/app/[locale]/console/page.tsx index 1dfbfe6..3dbb6df 100644 --- a/web/app/[locale]/console/page.tsx +++ b/web/app/[locale]/console/page.tsx @@ -1,6 +1,22 @@ import Link from "next/link"; +import { BillingRefreshListener } from "@/components/billing-refresh-listener"; import { CopyButton } from "@/components/copy-button"; -import { PopiartApiError, getBudgetSummary, getBudgetUsage, getPopiartEndpoint, getViewerSession } from "@/lib/popiart-api"; +import { GatewayBindForm } from "@/components/gateway-bind-form"; +import { + type BillingCredits, + type BillingInvoices, + type BillingSubscription, + PopiartApiError, + getBillingCredits, + getBillingInvoices, + getBillingSubscription, + getBudgetSummary, + getBudgetUsage, + getPopiartEndpoint, + getProjects, + getSkillsCatalog, + getViewerSession, +} from "@/lib/popiart-api"; import { getLiveCopy } from "@/lib/live-copy"; import { type Locale } from "@/lib/site-content"; @@ -30,6 +46,26 @@ function formatError(error: unknown) { return String(error); } +function coerceEpoch(value: unknown) { + const num = Number(value); + return Number.isFinite(num) && num > 0 ? num : 0; +} + +function extractRecentOrder(invoices: BillingInvoices | null) { + type BillingOrderItem = Record & { __kind: "subscription" | "points" }; + + const items: BillingOrderItem[] = [ + ...((invoices?.subscription_orders?.items || []).map((item) => ({ ...item, __kind: "subscription" as const }))), + ...((invoices?.point_orders?.items || []).map((item) => ({ ...item, __kind: "points" as const }))), + ]; + items.sort((a, b) => { + const aTime = coerceEpoch(a.complete_time) || coerceEpoch(a.create_time) || Number(a.id || 0); + const bTime = coerceEpoch(b.complete_time) || coerceEpoch(b.create_time) || Number(b.id || 0); + return bTime - aTime; + }); + return items[0] ?? null; +} + export default async function ConsolePage({ params, }: { @@ -51,17 +87,64 @@ export default async function ConsolePage({ const usedHint = isZh ? "本月累计消耗" : "Consumed this month"; const callsLabel = isZh ? "调用次数" : "API calls"; const callsHint = isZh ? "本月 CLI / API 调用" : "CLI / API calls this month"; + const projectsLabel = isZh ? "活跃项目" : "Projects"; + const projectsHint = isZh ? "当前账号下可见项目" : "Projects visible to this account"; const keysTitle = isZh ? "API 密钥" : "API keys"; const quickTitle = isZh ? "快速安装指令" : "Quick install commands"; const quickBody = isZh ? "把下面这段命令复制到终端,完成 CLI 安装、登录和验证。" : "Copy these commands into your terminal to install the CLI, sign in, and verify the workflow."; + const usageTitle = isZh ? "近期用量" : "Recent usage"; + const usageEmpty = isZh ? "当前周期还没有技能用量。" : "No usage has been recorded for the current period."; + const skillsTitle = isZh ? "官方技能" : "Official skills"; + const skillsEmpty = isZh ? "当前账号下没有可见技能。" : "No skills are visible for this account yet."; + const projectsTitle = isZh ? "项目" : "Projects"; + const projectsEmpty = isZh ? "当前账号下没有可见项目。" : "No projects are visible for this account yet."; + const billingTitle = isZh ? "网关账单" : "Gateway billing"; + const billingStatusTitle = isZh ? "订阅状态" : "Subscription"; + const billingCreditsTitle = isZh ? "积分余额" : "Credits"; + const recentOrderTitle = isZh ? "最近订单" : "Recent order"; + const recentOrderEmpty = isZh ? "当前还没有账单订单记录。" : "No billing orders yet."; + const billingUnbound = isZh + ? "当前 session 还没有绑定网关用户态,先绑定后才能读取真实订阅与积分。" + : "This session is not bound to a gateway user yet. Bind it first to read live subscription and credit data."; + const billingEmpty = isZh ? "当前没有有效订阅。" : "No active subscription was returned."; + const walletEmpty = isZh ? "当前没有积分钱包记录。" : "No point wallets were returned."; + const boundLabel = isZh ? "已绑定网关用户" : "Gateway user bound"; + const preferenceLabel = isZh ? "扣费偏好" : "Billing preference"; + const activeSubscriptionLabel = isZh ? "有效订阅" : "Active subscriptions"; + const creditBalanceLabel = isZh ? "当前余额" : "Balance"; + const walletCountLabel = isZh ? "钱包数量" : "Wallets"; + const availablePointsLabel = isZh ? "可用积分" : "Available points"; + const totalPointsLabel = isZh ? "总积分" : "Total points"; + const walletSourceLabel = isZh ? "来源" : "Source"; + const routeKeyLabel = isZh ? "路由键" : "Route key"; + const usageJobsLabel = isZh ? "调用次数" : "Jobs"; + const usageTokensLabel = isZh ? "Tokens" : "Tokens"; + const usageCostLabel = isZh ? "费用" : "Cost"; const sessionKeyLabel = "POPIART_SESSION_KEY"; const endpointLabel = "POPIART_ENDPOINT"; const copyLabel = isZh ? "复制" : "Copy"; const copiedLabel = isZh ? "已复制" : "Copied"; const loginCta = isZh ? "去登录" : "Sign in"; const docsCta = isZh ? "查看文档" : "Open docs"; + const bindLabels = { + title: isZh ? "绑定网关用户" : "Bind gateway user", + body: isZh + ? "输入网关真实用户 ID 和 user access_token,当前 session 才能读取订阅、积分包和订单。" + : "Enter the real gateway user ID and user access token so this session can read subscriptions, point packs, and orders.", + userIdLabel: isZh ? "网关用户 ID" : "Gateway user ID", + userIdHint: isZh ? "值必须等于网关当前登录用户的真实 ID。" : "This must match the real ID of the gateway user.", + tokenLabel: isZh ? "网关 access_token" : "Gateway access token", + tokenHint: isZh + ? "使用网关 `/api/user/token` 生成的普通用户 access_token,不是渠道 key。" + : "Use the user access token generated by gateway `/api/user/token`, not a channel key.", + submit: isZh ? "绑定网关账单" : "Bind gateway billing", + submitting: isZh ? "绑定中..." : "Binding...", + invalidUserId: isZh ? "请输入有效的网关用户 ID。" : "Enter a valid gateway user ID.", + invalidToken: isZh ? "请输入有效的网关 access_token。" : "Enter a valid gateway access token.", + success: isZh ? "绑定成功,正在刷新账单数据。" : "Binding succeeded. Refreshing billing data.", + }; if (!session) { return ( @@ -91,12 +174,47 @@ export default async function ConsolePage({ ); } - const [budgetResult, usageResult] = await Promise.allSettled([getBudgetSummary(), getBudgetUsage()]); + const [budgetResult, usageResult, skillsResult, projectsResult] = await Promise.allSettled([ + getBudgetSummary(), + getBudgetUsage(), + getSkillsCatalog(), + getProjects(), + ]); const budget = budgetResult.status === "fulfilled" ? budgetResult.value : null; const usage = usageResult.status === "fulfilled" ? usageResult.value : null; - const loadErrors = [budgetResult, usageResult] + const skills = skillsResult.status === "fulfilled" ? skillsResult.value : null; + const projects = projectsResult.status === "fulfilled" ? projectsResult.value : null; + const loadErrors = [budgetResult, usageResult, skillsResult, projectsResult] .filter((result) => result.status === "rejected") .map((result) => formatError((result as PromiseRejectedResult).reason)); + let billingSubscription: BillingSubscription | null = null; + let billingCredits: BillingCredits | null = null; + let billingInvoices: BillingInvoices | null = null; + const billingErrors: string[] = []; + + if (session.gateway_bound) { + const [subscriptionResult, creditsResult, invoicesResult] = await Promise.allSettled([ + getBillingSubscription(), + getBillingCredits(), + getBillingInvoices({ page: 1, pageSize: 1 }), + ]); + if (subscriptionResult.status === "fulfilled") { + billingSubscription = subscriptionResult.value; + } else { + billingErrors.push(formatError(subscriptionResult.reason)); + } + if (creditsResult.status === "fulfilled") { + billingCredits = creditsResult.value; + } else { + billingErrors.push(formatError(creditsResult.reason)); + } + if (invoicesResult.status === "fulfilled") { + billingInvoices = invoicesResult.value; + } else { + billingErrors.push(formatError(invoicesResult.reason)); + } + } + const recentOrder = extractRecentOrder(billingInvoices); const metrics = [ { @@ -114,6 +232,11 @@ export default async function ConsolePage({ value: usage ? formatNumber(typedLocale, usage.total.job_count) : "--", hint: callsHint, }, + { + label: projectsLabel, + value: projects ? formatNumber(typedLocale, projects.total) : "--", + hint: projectsHint, + }, ]; const quickStart = [ @@ -126,6 +249,8 @@ export default async function ConsolePage({ return (
+ +

{pageTitle}

@@ -187,6 +312,137 @@ export default async function ConsolePage({
+
+ {!session.gateway_bound ? ( + + ) : ( + <> +
+

{billingTitle}

+

+ {boundLabel}: {session.gateway_user_id} +

+
+
+ + {isZh ? "查看账单中心" : "Open billing"} + +
+ {billingErrors.length > 0 ? ( +
+ {billingErrors.join(" | ")} +
+ ) : null} +
+
+
+

{billingStatusTitle}

+
+ {billingSubscription && billingSubscription.subscriptions.length > 0 ? ( +
+
+
+ {preferenceLabel} + {billingSubscription.billing_preference || "-"} +
+
+ {activeSubscriptionLabel} + {formatNumber(typedLocale, billingSubscription.subscriptions.length)} +
+
+ {billingSubscription.subscriptions.map((item, index) => ( +
+
+ {item.subscription?.member_level || "subscription"} + {item.subscription?.status || "-"} +
+
+ {availablePointsLabel} + + {formatNumber( + typedLocale, + billingSubscription.subscription_points[String(item.subscription?.id)]?.available_points || 0, + )} + +
+
+ ))} +
+ ) : ( +

{billingEmpty}

+ )} +
+ +
+
+

{billingCreditsTitle}

+
+ {billingCredits ? ( +
+
+
+ {creditBalanceLabel} + {formatNumber(typedLocale, billingCredits.balance)} +
+
+ {walletCountLabel} + {formatNumber(typedLocale, billingCredits.wallets.length)} +
+
+ {billingCredits.wallets.slice(0, 4).map((wallet) => ( +
+
+ {walletSourceLabel}: {wallet.source_type} + {availablePointsLabel}: {formatNumber(typedLocale, wallet.points)} +
+
+ {totalPointsLabel} + {formatNumber(typedLocale, wallet.points_total)} +
+
+ ))} +
+ ) : ( +

{walletEmpty}

+ )} +
+ +
+
+

{recentOrderTitle}

+
+ {recentOrder ? ( +
+
+
+ {String(recentOrder.plan_title || recentOrder.package_name || recentOrder.trade_no || "order")} + {String(recentOrder.status || "-")} +
+
+ {String(recentOrder.money || "-")} {String(recentOrder.currency || "")} + {String(recentOrder.payment_method || "-")} +
+
+
+
+ {isZh ? "交易号" : "Trade no"} + {String(recentOrder.trade_no || "-")} +
+
+ {isZh ? "订单类型" : "Order kind"} + {recentOrder.__kind === "subscription" ? (isZh ? "订阅订单" : "Subscription") : (isZh ? "积分包订单" : "Points pack")} +
+
+
+ ) : ( +

{recentOrderEmpty}

+ )} +
+
+ + )} +
+

{quickTitle}

@@ -198,6 +454,84 @@ export default async function ConsolePage({
+ +
+
+

{usageTitle}

+
+ {usage && usage.rows.length > 0 ? ( +
+ {usage.rows.map((row) => ( +
+
+ {row.dimension} + + {usageJobsLabel}: {formatNumber(typedLocale, row.job_count)} + +
+
+ + {usageTokensLabel}: {formatNumber(typedLocale, row.tokens_used)} + + + {usageCostLabel}: ${row.cost_usd.toFixed(2)} + +
+
+ ))} +
+ ) : ( +

{usageEmpty}

+ )} +
+ +
+
+
+

{skillsTitle}

+
+ {skills && skills.items.length > 0 ? ( +
+ {skills.items.slice(0, 6).map((skill) => ( +
+
+ {skill.name} + {skill.description} +
+
+ {skill.version} + + {routeKeyLabel}: {skill.route_key || skill.id} + +
+
+ ))} +
+ ) : ( +

{skillsEmpty}

+ )} +
+ +
+
+

{projectsTitle}

+
+ {projects && projects.items.length > 0 ? ( +
+ {projects.items.map((project) => ( +
+
+ {project.name} + {project.id} +
+
+ ))} +
+ ) : ( +

{projectsEmpty}

+ )} +
+
); } diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx index 155d832..4715bfd 100644 --- a/web/app/[locale]/pricing/page.tsx +++ b/web/app/[locale]/pricing/page.tsx @@ -1,4 +1,11 @@ import Link from "next/link"; +import { BillingPurchaseActions } from "@/components/billing-purchase-actions"; +import { + getBillingCatalog, + getBillingPointsPackages, + getBillingSubscriptionPlans, + getViewerSession, +} from "@/lib/popiart-api"; import { getDictionary, type Locale } from "@/lib/site-content"; export default async function PricingPage({ @@ -7,7 +14,64 @@ export default async function PricingPage({ params: Promise<{ locale: string }>; }) { const { locale } = await params; - const dictionary = getDictionary(locale as Locale); + const typedLocale = locale as Locale; + const dictionary = getDictionary(typedLocale); + const session = await getViewerSession(); + const isZh = typedLocale === "zh"; + let plans = dictionary.pricing.plans; + let subscriptionPlans: Awaited> = null; + let pointsPackages: Awaited> = null; + let billingLiveError = ""; + const bindHint = isZh + ? "如需查看真实套餐与发起支付,请先在控制台绑定网关用户态。" + : "Bind a gateway user in the console first to see live plans and start checkout."; + const purchaseLabels = { + alipay: isZh ? "支付宝支付" : "Pay with Alipay", + wxpay: isZh ? "微信支付" : "Pay with WeChat", + creating: isZh ? "创建中..." : "Creating...", + tradeNo: isZh ? "交易号" : "Trade no", + openLink: isZh ? "打开支付链接" : "Open payment link", + codeUrl: isZh ? "扫码地址" : "Code URL", + invalid: isZh ? "创建支付失败。" : "Failed to create payment.", + pending: isZh ? "支付处理中" : "Payment pending", + success: isZh ? "支付成功" : "Payment successful", + failed: isZh ? "支付失败" : "Payment failed", + paymentStatus: isZh ? "支付状态" : "Payment status", + qrTitle: isZh ? "微信扫码支付" : "Scan with WeChat", + openConsole: isZh ? "前往控制台" : "Open console", + openBilling: isZh ? "查看账单中心" : "Open billing", + }; + + try { + const catalog = await getBillingCatalog(); + if (catalog?.plans?.length) { + plans = catalog.plans.map((plan) => ({ + badge: plan.badge, + name: plan.name, + price: plan.price, + cadence: plan.cadence, + summary: plan.summary, + features: plan.features, + cta: plan.cta, + highlight: plan.highlight, + })); + } + } catch { + // Keep the seeded dictionary fallback when the product billing catalog is unavailable. + } + + if (session?.gateway_bound) { + try { + const [subscriptionResult, pointsResult] = await Promise.all([ + getBillingSubscriptionPlans(), + getBillingPointsPackages(), + ]); + subscriptionPlans = subscriptionResult; + pointsPackages = pointsResult; + } catch (error) { + billingLiveError = error instanceof Error ? error.message : String(error); + } + } return (
@@ -17,13 +81,52 @@ export default async function PricingPage({

{dictionary.pricing.title}

{dictionary.pricing.subtitle}

+ {session ? ( +
+ {session.gateway_bound ? ( + {isZh ? "已绑定网关用户,可直接读取真实套餐并发起支付。" : "Gateway user bound. Live plans and checkout are available."} + ) : ( + {bindHint} + )} +
+ ) : null} + {billingLiveError ?
{billingLiveError}
: null}
- {dictionary.pricing.plans.map((plan) => ( + {(subscriptionPlans?.items.length ? subscriptionPlans.items.map((item) => ({ + key: String(item.plan.id), + badge: item.plan.recommended ? (isZh ? "推荐方案" : "Recommended") : (isZh ? "订阅方案" : "Subscription"), + name: item.plan.title, + price: `${item.plan.currency} ${item.plan.price_amount}`, + cadence: `${item.plan.duration_value} ${item.plan.duration_unit}`, + summary: item.plan.description || item.plan.subtitle, + features: [ + `${item.plan.points_amount} ${isZh ? "订阅赠送积分" : "subscription points"}`, + `${item.plan.total_amount || 0} ${isZh ? "总额度" : "total quota"}`, + `${isZh ? "会员等级" : "member level"}: ${item.plan.member_level || "-"}`, + `${isZh ? "重置周期" : "reset period"}: ${item.plan.quota_reset_period || "-"}`, + ], + cta: isZh ? "购买订阅" : "Buy subscription", + highlight: item.plan.recommended, + itemId: item.plan.id, + kind: "subscription" as const, + })) : plans.map((plan) => ({ + key: plan.name, + badge: plan.badge, + name: plan.name, + price: plan.price, + cadence: plan.cadence, + summary: plan.summary, + features: plan.features, + cta: plan.cta, + highlight: plan.highlight, + itemId: 0, + kind: "subscription" as const, + }))).map((plan) => (
@@ -41,13 +144,47 @@ export default async function PricingPage({
  • {feature}
  • ))} - - {plan.cta} - + {session?.gateway_bound && plan.itemId > 0 ? ( + + ) : ( + + {plan.cta} + + )}
    ))}
    + {pointsPackages?.items.length ? ( +
    + {pointsPackages.items.map((pack) => ( +
    +
    +
    + {isZh ? "积分包" : "Points pack"} +

    {pack.name}

    +
    +
    + {pack.currency} {pack.price_amount} + {isZh ? "/包" : "/pack"} +
    +
    +

    + {isZh + ? `到账 ${pack.points_amount + pack.bonus_points} 积分(含赠送 ${pack.bonus_points})` + : `${pack.points_amount + pack.bonus_points} total points (${pack.bonus_points} bonus)`} +

    +
      +
    • {isZh ? `基础积分 ${pack.points_amount}` : `Base points ${pack.points_amount}`}
    • +
    • {isZh ? `赠送积分 ${pack.bonus_points}` : `Bonus points ${pack.bonus_points}`}
    • +
    • {isZh ? "购买成功后直接进入积分钱包" : "Delivered into your point wallets after payment"}
    • +
    + +
    + ))} +
    + ) : null} +
    {dictionary.pricing.faqTag}
    diff --git a/web/app/api/auth/gateway-bind/route.ts b/web/app/api/auth/gateway-bind/route.ts new file mode 100644 index 0000000..5c7c088 --- /dev/null +++ b/web/app/api/auth/gateway-bind/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + SESSION_COOKIE_NAME, + type GatewayBindResponse, + popiartFetchEnvelope, +} from "@/lib/popiart-api"; + +export async function POST(request: Request) { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { + ok: false, + error: { + code: "UNAUTHENTICATED", + message: "missing local popiart session", + }, + }, + { status: 401 }, + ); + } + + let body: { gateway_user_id?: number; gateway_access_token?: string } = {}; + + try { + body = (await request.json()) as { gateway_user_id?: number; gateway_access_token?: string }; + } catch { + body = {}; + } + + try { + const { response, payload } = await popiartFetchEnvelope("/auth/gateway/bind", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + return NextResponse.json( + payload ?? { + ok: false, + error: { + code: "SERVER_ERROR", + message: "invalid response from popiartServer", + }, + }, + { status: response.status }, + ); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: { + code: "SERVER_ERROR", + message: "failed to reach popiartServer", + details: error instanceof Error ? error.message : String(error), + }, + }, + { status: 502 }, + ); + } +} diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..b376d10 --- /dev/null +++ b/web/app/api/billing/checkout/route.ts @@ -0,0 +1,150 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + SESSION_COOKIE_NAME, + type BillingCheckoutResult, + popiartFetchEnvelope, +} from "@/lib/popiart-api"; + +export async function POST(request: Request) { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { + ok: false, + error: { + code: "UNAUTHENTICATED", + message: "missing local popiart session", + }, + }, + { status: 401 }, + ); + } + + let body: { + kind?: "subscription" | "points"; + provider?: string; + plan_id?: number; + package_id?: number; + return_url?: string; + } = {}; + + try { + body = (await request.json()) as typeof body; + } catch { + body = {}; + } + + const pathname = + body.kind === "subscription" + ? "/billing/checkout/subscription" + : body.kind === "points" + ? "/billing/checkout/points" + : ""; + + if (!pathname) { + return NextResponse.json( + { + ok: false, + error: { + code: "VALIDATION_ERROR", + message: "kind must be subscription or points", + }, + }, + { status: 400 }, + ); + } + + try { + const { response, payload } = await popiartFetchEnvelope(pathname, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + return NextResponse.json( + payload ?? { + ok: false, + error: { + code: "SERVER_ERROR", + message: "invalid response from popiartServer", + }, + }, + { status: response.status }, + ); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: { + code: "SERVER_ERROR", + message: "failed to reach popiartServer", + details: error instanceof Error ? error.message : String(error), + }, + }, + { status: 502 }, + ); + } +} + +export async function GET(request: Request) { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { + ok: false, + error: { + code: "UNAUTHENTICATED", + message: "missing local popiart session", + }, + }, + { status: 401 }, + ); + } + + const url = new URL(request.url); + const query = new URLSearchParams(url.searchParams); + + try { + const { response, payload } = await popiartFetchEnvelope<{ + message?: string; + status?: string; + data?: Record; + }>(`/billing/checkout/status?${query.toString()}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return NextResponse.json( + payload ?? { + ok: false, + error: { + code: "SERVER_ERROR", + message: "invalid response from popiartServer", + }, + }, + { status: response.status }, + ); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: { + code: "SERVER_ERROR", + message: "failed to reach popiartServer", + details: error instanceof Error ? error.message : String(error), + }, + }, + { status: 502 }, + ); + } +} diff --git a/web/app/globals.css b/web/app/globals.css index e0de433..636de26 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1084,6 +1084,113 @@ code { align-content: start; } +.billing-purchase-stack { + display: grid; + gap: 12px; +} + +.billing-purchase-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.billing-purchase-result { + display: grid; + gap: 6px; +} + +.billing-purchase-result a { + color: var(--accent-strong); + font-weight: 600; +} + +.billing-success-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 6px; +} + +.billing-order-detail { + border-top: 1px solid var(--line); +} + +.billing-order-detail:first-child { + border-top: 0; +} + +.billing-order-summary { + list-style: none; + cursor: pointer; +} + +.billing-order-summary::-webkit-details-marker { + display: none; +} + +.billing-order-body { + padding: 0 0 12px; +} + +.billing-filter-row, +.billing-filter-group, +.billing-filter-links, +.billing-pagination { + display: flex; +} + +.billing-filter-row { + justify-content: space-between; + align-items: flex-start; + gap: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--line); +} + +.billing-filter-group { + flex-direction: column; + gap: 10px; +} + +.billing-filter-links { + flex-wrap: wrap; + gap: 10px; +} + +.billing-pagination { + justify-content: space-between; + align-items: center; + gap: 16px; + padding-top: 16px; +} + +.pill-active { + background: rgba(44, 107, 255, 0.12); + border-color: rgba(44, 107, 255, 0.18); + color: var(--accent-strong); +} + +.button-disabled { + pointer-events: none; + opacity: 0.55; +} + +.billing-qr-block { + display: grid; + gap: 10px; + margin-top: 8px; +} + +.billing-qr-image { + width: 180px; + height: 180px; + border-radius: 18px; + border: 1px solid var(--line); + background: white; + padding: 10px; +} + .pricing-card h3 { margin: 12px 0 8px; font-family: var(--font-display); @@ -1399,7 +1506,7 @@ code { .console-metrics-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; } @@ -1441,6 +1548,18 @@ code { padding: 30px; } +.gateway-bind-stack { + display: grid; + gap: 18px; +} + +.billing-nested-card { + padding: 24px; + border-radius: 24px; + background: linear-gradient(180deg, rgba(250, 251, 255, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%); + box-shadow: none; +} + .console-key-list { display: grid; gap: 0; diff --git a/web/components/billing-page-state.tsx b/web/components/billing-page-state.tsx new file mode 100644 index 0000000..6920276 --- /dev/null +++ b/web/components/billing-page-state.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +const BILLING_STATE_KEY = "popiart-billing-page-state"; + +export function BillingPageState() { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + const hasQuery = searchParams.toString().length > 0; + if (hasQuery) { + try { + window.sessionStorage.setItem(BILLING_STATE_KEY, searchParams.toString()); + } catch { + // Ignore storage write failures. + } + return; + } + + try { + const stored = window.sessionStorage.getItem(BILLING_STATE_KEY); + if (stored) { + router.replace(`${pathname}?${stored}`); + } + } catch { + // Ignore storage read failures. + } + }, [pathname, router, searchParams]); + + return null; +} diff --git a/web/components/billing-purchase-actions.tsx b/web/components/billing-purchase-actions.tsx new file mode 100644 index 0000000..29c2f5a --- /dev/null +++ b/web/components/billing-purchase-actions.tsx @@ -0,0 +1,208 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { emitBillingRefreshSignal } from "@/components/billing-refresh-listener"; +import { QRCodeSVG } from "@/components/vendor/qrcode-react"; + +type ApiResponse = { + ok: boolean; + data?: { + message?: string; + trade_no?: string; + url?: string; + code_url?: string; + status?: string; + }; + error?: { + message?: string; + }; +}; + +export function BillingPurchaseActions({ + kind, + itemId, + labels, +}: { + kind: "subscription" | "points"; + itemId: number; + labels: { + alipay: string; + wxpay: string; + creating: string; + tradeNo: string; + openLink: string; + codeUrl: string; + invalid: string; + pending: string; + success: string; + failed: string; + paymentStatus: string; + qrTitle: string; + openConsole: string; + openBilling: string; + }; +}) { + const pathname = usePathname(); + const router = useRouter(); + const [result, setResult] = useState<(ApiResponse["data"] & { provider?: string; kind?: string }) | null>(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState(""); + const [pendingProvider, setPendingProvider] = useState(null); + const [isPending, startTransition] = useTransition(); + const locale = useMemo(() => pathname.split("/")[1] || "zh", [pathname]); + + function checkout(provider: "alipay" | "wxpay") { + startTransition(async () => { + setPendingProvider(provider); + setError(null); + setResult(null); + setStatus(""); + try { + const response = await fetch("/api/billing/checkout", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + kind, + provider, + plan_id: kind === "subscription" ? itemId : undefined, + package_id: kind === "points" ? itemId : undefined, + return_url: typeof window !== "undefined" ? `${window.location.origin}${pathname}` : "", + }), + }); + const payload = (await response.json()) as ApiResponse; + if (!response.ok || !payload.ok) { + setError(payload.error?.message ?? labels.invalid); + return; + } + setResult({ ...(payload.data ?? null), provider, kind }); + setStatus(labels.pending); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : labels.invalid); + } finally { + setPendingProvider(null); + } + }); + } + + useEffect(() => { + if (!result?.trade_no || !result.provider || !result.kind) { + return; + } + const currentResult = result; + const currentKind = currentResult.kind ?? ""; + const currentProvider = currentResult.provider ?? ""; + if (!currentKind || !currentProvider) { + return; + } + let cancelled = false; + let attempts = 0; + + async function poll() { + attempts += 1; + try { + const query = new URLSearchParams(); + query.set("kind", currentKind); + query.set("provider", currentProvider); + query.set("trade_no", currentResult.trade_no ?? ""); + const response = await fetch(`/api/billing/checkout?${query.toString()}`); + const payload = (await response.json()) as ApiResponse; + if (!response.ok || !payload.ok) { + if (!cancelled) { + setError(payload.error?.message ?? labels.invalid); + } + return; + } + const nextStatus = payload.data?.status ?? ""; + if (!cancelled && nextStatus) { + setStatus(nextStatus); + } + if (cancelled) { + return; + } + if (nextStatus === "SUCCESS" || nextStatus === "TRADE_SUCCESS") { + setStatus(labels.success); + emitBillingRefreshSignal(); + window.setTimeout(() => { + router.push( + `/${locale}/billing/success?kind=${encodeURIComponent(currentKind)}&provider=${encodeURIComponent(currentProvider)}&trade_no=${encodeURIComponent(currentResult.trade_no ?? "")}`, + ); + }, 1200); + return; + } + if (nextStatus === "FAILED" || nextStatus === "CLOSED" || nextStatus === "TRADE_CLOSED") { + setStatus(labels.failed); + return; + } + if (attempts < 20) { + window.setTimeout(poll, 3000); + } + } catch (requestError) { + if (!cancelled) { + setError(requestError instanceof Error ? requestError.message : labels.invalid); + } + } + } + + const timer = window.setTimeout(poll, 2000); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [labels.failed, labels.invalid, labels.success, locale, result, router]); + + return ( +
    +
    + + +
    + {error ?
    {error}
    : null} + {result ? ( +
    + {result.trade_no ? ( + + {labels.tradeNo}: {result.trade_no} + + ) : null} + {status ? ( + + {labels.paymentStatus}: {status} + + ) : null} + {result.url ? ( + + {labels.openLink} + + ) : null} + {result.code_url ? {labels.codeUrl}: {result.code_url} : null} + {result.code_url ? ( +
    + {labels.qrTitle} +
    + +
    +
    + ) : null} + {status === labels.success ? ( +
    + + {labels.openConsole} + + + {labels.openBilling} + +
    + ) : null} +
    + ) : null} +
    + ); +} diff --git a/web/components/billing-refresh-listener.tsx b/web/components/billing-refresh-listener.tsx new file mode 100644 index 0000000..24cbe06 --- /dev/null +++ b/web/components/billing-refresh-listener.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +const BILLING_REFRESH_KEY = "popiart-billing-refresh"; +const BILLING_REFRESH_EVENT = "popiart:billing-refresh"; + +export function emitBillingRefreshSignal() { + const value = String(Date.now()); + try { + window.localStorage.setItem(BILLING_REFRESH_KEY, value); + } catch { + // Ignore storage failures and still dispatch the in-page event. + } + window.dispatchEvent(new CustomEvent(BILLING_REFRESH_EVENT, { detail: value })); +} + +export function BillingRefreshListener() { + const router = useRouter(); + + useEffect(() => { + function handleStorage(event: StorageEvent) { + if (event.key === BILLING_REFRESH_KEY && event.newValue) { + router.refresh(); + } + } + + function handleCustomEvent() { + router.refresh(); + } + + window.addEventListener("storage", handleStorage); + window.addEventListener(BILLING_REFRESH_EVENT, handleCustomEvent); + + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener(BILLING_REFRESH_EVENT, handleCustomEvent); + }; + }, [router]); + + return null; +} + +export function BillingAutoRefresh({ + active, + intervalMs = 5000, + maxRefreshes = 12, +}: { + active: boolean; + intervalMs?: number; + maxRefreshes?: number; +}) { + const router = useRouter(); + + useEffect(() => { + if (!active) { + return; + } + let count = 0; + const timer = window.setInterval(() => { + count += 1; + router.refresh(); + if (count >= maxRefreshes) { + window.clearInterval(timer); + } + }, intervalMs); + return () => { + window.clearInterval(timer); + }; + }, [active, intervalMs, maxRefreshes, router]); + + return null; +} diff --git a/web/components/gateway-bind-form.tsx b/web/components/gateway-bind-form.tsx new file mode 100644 index 0000000..bce06c4 --- /dev/null +++ b/web/components/gateway-bind-form.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; + +type ApiResponse = { + ok: boolean; + error?: { + message?: string; + }; +}; + +export function GatewayBindForm({ + labels, +}: { + labels: { + title: string; + body: string; + userIdLabel: string; + userIdHint: string; + tokenLabel: string; + tokenHint: string; + submit: string; + submitting: string; + invalidUserId: string; + invalidToken: string; + success: string; + }; +}) { + const router = useRouter(); + const [gatewayUserId, setGatewayUserId] = useState(""); + const [gatewayAccessToken, setGatewayAccessToken] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [isPending, startTransition] = useTransition(); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const userId = Number(gatewayUserId.trim()); + const accessToken = gatewayAccessToken.trim(); + + if (!Number.isInteger(userId) || userId <= 0) { + setError(labels.invalidUserId); + setSuccess(null); + return; + } + if (!accessToken) { + setError(labels.invalidToken); + setSuccess(null); + return; + } + + startTransition(async () => { + setError(null); + setSuccess(null); + + try { + const response = await fetch("/api/auth/gateway-bind", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + gateway_user_id: userId, + gateway_access_token: accessToken, + }), + }); + const payload = (await response.json()) as ApiResponse; + + if (!response.ok || !payload.ok) { + setError(payload.error?.message ?? labels.invalidToken); + return; + } + + setSuccess(labels.success); + router.refresh(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : labels.invalidToken); + } + }); + } + + return ( +
    +
    +

    {labels.title}

    +

    {labels.body}

    +
    +
    + + setGatewayUserId(event.target.value)} + placeholder="123" + type="text" + value={gatewayUserId} + /> +

    {labels.userIdHint}

    + + + setGatewayAccessToken(event.target.value)} + placeholder="access_token" + spellCheck={false} + type="password" + value={gatewayAccessToken} + /> +

    {labels.tokenHint}

    + + {error ?
    {error}
    : null} + {success ?
    {success}
    : null} + + +
    +
    + ); +} diff --git a/web/components/main-nav.tsx b/web/components/main-nav.tsx index 63dafeb..36aed64 100644 --- a/web/components/main-nav.tsx +++ b/web/components/main-nav.tsx @@ -29,6 +29,7 @@ export function MainNav({ skills: string; console: string; pricing: string; + billing?: string; }; }) { const pathname = usePathname(); @@ -39,6 +40,9 @@ export function MainNav({ { href: localize(locale, "/console"), label: labels.console }, { href: localize(locale, "/pricing"), label: labels.pricing }, ]; + if (labels.billing) { + items.push({ href: localize(locale, "/billing"), label: labels.billing }); + } return (
    diff --git a/web/components/vendor/qrcode-react.d.ts b/web/components/vendor/qrcode-react.d.ts new file mode 100644 index 0000000..209348f --- /dev/null +++ b/web/components/vendor/qrcode-react.d.ts @@ -0,0 +1,11 @@ +declare module "@/components/vendor/qrcode-react" { + import * as React from "react"; + + export interface QRCodeSVGProps extends React.SVGProps { + value: string; + size?: number; + level?: "L" | "M" | "Q" | "H"; + } + + export const QRCodeSVG: React.ComponentType; +} diff --git a/web/components/vendor/qrcode-react.js b/web/components/vendor/qrcode-react.js new file mode 100644 index 0000000..aa947a7 --- /dev/null +++ b/web/components/vendor/qrcode-react.js @@ -0,0 +1,1137 @@ +var __defProp = Object.defineProperty; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __objRest = (source, exclude) => { + var target = {}; + for (var prop in source) + if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0) + target[prop] = source[prop]; + if (source != null && __getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(source)) { + if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop)) + target[prop] = source[prop]; + } + return target; +}; + +// src/index.tsx +import React from "react"; + +// src/third-party/qrcodegen/index.ts +/** + * @license QR Code generator library (TypeScript) + * Copyright (c) Project Nayuki. + * SPDX-License-Identifier: MIT + */ +var qrcodegen; +((qrcodegen2) => { + const _QrCode = class _QrCode { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code with the given version number, + // error correction level, data codeword bytes, and mask number. + // This is a low-level API that most users should not use directly. + // A mid-level API is the encodeSegments() function. + constructor(version, errorCorrectionLevel, dataCodewords, msk) { + this.version = version; + this.errorCorrectionLevel = errorCorrectionLevel; + // The modules of this QR Code (false = light, true = dark). + // Immutable after constructor finishes. Accessed through getModule(). + this.modules = []; + // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. + this.isFunction = []; + if (version < _QrCode.MIN_VERSION || version > _QrCode.MAX_VERSION) + throw new RangeError("Version value out of range"); + if (msk < -1 || msk > 7) + throw new RangeError("Mask value out of range"); + this.size = version * 4 + 17; + let row = []; + for (let i = 0; i < this.size; i++) + row.push(false); + for (let i = 0; i < this.size; i++) { + this.modules.push(row.slice()); + this.isFunction.push(row.slice()); + } + this.drawFunctionPatterns(); + const allCodewords = this.addEccAndInterleave(dataCodewords); + this.drawCodewords(allCodewords); + if (msk == -1) { + let minPenalty = 1e9; + for (let i = 0; i < 8; i++) { + this.applyMask(i); + this.drawFormatBits(i); + const penalty = this.getPenaltyScore(); + if (penalty < minPenalty) { + msk = i; + minPenalty = penalty; + } + this.applyMask(i); + } + } + assert(0 <= msk && msk <= 7); + this.mask = msk; + this.applyMask(msk); + this.drawFormatBits(msk); + this.isFunction = []; + } + /*-- Static factory functions (high level) --*/ + // Returns a QR Code representing the given Unicode text string at the given error correction level. + // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer + // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible + // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the + // ecl argument if it can be done without increasing the version. + static encodeText(text, ecl) { + const segs = qrcodegen2.QrSegment.makeSegments(text); + return _QrCode.encodeSegments(segs, ecl); + } + // Returns a QR Code representing the given binary data at the given error correction level. + // This function always encodes using the binary segment mode, not any text mode. The maximum number of + // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. + // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. + static encodeBinary(data, ecl) { + const seg = qrcodegen2.QrSegment.makeBytes(data); + return _QrCode.encodeSegments([seg], ecl); + } + /*-- Static factory functions (mid level) --*/ + // Returns a QR Code representing the given segments with the given encoding parameters. + // The smallest possible QR Code version within the given range is automatically + // chosen for the output. Iff boostEcl is true, then the ECC level of the result + // may be higher than the ecl argument if it can be done without increasing the + // version. The mask number is either between 0 to 7 (inclusive) to force that + // mask, or -1 to automatically choose an appropriate mask (which may be slow). + // This function allows the user to create a custom sequence of segments that switches + // between modes (such as alphanumeric and byte) to encode text in less space. + // This is a mid-level API; the high-level API is encodeText() and encodeBinary(). + static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) { + if (!(_QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= _QrCode.MAX_VERSION) || mask < -1 || mask > 7) + throw new RangeError("Invalid value"); + let version; + let dataUsedBits; + for (version = minVersion; ; version++) { + const dataCapacityBits2 = _QrCode.getNumDataCodewords(version, ecl) * 8; + const usedBits = QrSegment.getTotalBits(segs, version); + if (usedBits <= dataCapacityBits2) { + dataUsedBits = usedBits; + break; + } + if (version >= maxVersion) + throw new RangeError("Data too long"); + } + for (const newEcl of [_QrCode.Ecc.MEDIUM, _QrCode.Ecc.QUARTILE, _QrCode.Ecc.HIGH]) { + if (boostEcl && dataUsedBits <= _QrCode.getNumDataCodewords(version, newEcl) * 8) + ecl = newEcl; + } + let bb = []; + for (const seg of segs) { + appendBits(seg.mode.modeBits, 4, bb); + appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb); + for (const b of seg.getData()) + bb.push(b); + } + assert(bb.length == dataUsedBits); + const dataCapacityBits = _QrCode.getNumDataCodewords(version, ecl) * 8; + assert(bb.length <= dataCapacityBits); + appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb); + appendBits(0, (8 - bb.length % 8) % 8, bb); + assert(bb.length % 8 == 0); + for (let padByte = 236; bb.length < dataCapacityBits; padByte ^= 236 ^ 17) + appendBits(padByte, 8, bb); + let dataCodewords = []; + while (dataCodewords.length * 8 < bb.length) + dataCodewords.push(0); + bb.forEach((b, i) => dataCodewords[i >>> 3] |= b << 7 - (i & 7)); + return new _QrCode(version, ecl, dataCodewords, mask); + } + /*-- Accessor methods --*/ + // Returns the color of the module (pixel) at the given coordinates, which is false + // for light or true for dark. The top left corner has the coordinates (x=0, y=0). + // If the given coordinates are out of bounds, then false (light) is returned. + getModule(x, y) { + return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x]; + } + // Modified to expose modules for easy access + getModules() { + return this.modules; + } + /*-- Private helper methods for constructor: Drawing function modules --*/ + // Reads this object's version field, and draws and marks all function modules. + drawFunctionPatterns() { + for (let i = 0; i < this.size; i++) { + this.setFunctionModule(6, i, i % 2 == 0); + this.setFunctionModule(i, 6, i % 2 == 0); + } + this.drawFinderPattern(3, 3); + this.drawFinderPattern(this.size - 4, 3); + this.drawFinderPattern(3, this.size - 4); + const alignPatPos = this.getAlignmentPatternPositions(); + const numAlign = alignPatPos.length; + for (let i = 0; i < numAlign; i++) { + for (let j = 0; j < numAlign; j++) { + if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0)) + this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]); + } + } + this.drawFormatBits(0); + this.drawVersion(); + } + // Draws two copies of the format bits (with its own error correction code) + // based on the given mask and this object's error correction level field. + drawFormatBits(mask) { + const data = this.errorCorrectionLevel.formatBits << 3 | mask; + let rem = data; + for (let i = 0; i < 10; i++) + rem = rem << 1 ^ (rem >>> 9) * 1335; + const bits = (data << 10 | rem) ^ 21522; + assert(bits >>> 15 == 0); + for (let i = 0; i <= 5; i++) + this.setFunctionModule(8, i, getBit(bits, i)); + this.setFunctionModule(8, 7, getBit(bits, 6)); + this.setFunctionModule(8, 8, getBit(bits, 7)); + this.setFunctionModule(7, 8, getBit(bits, 8)); + for (let i = 9; i < 15; i++) + this.setFunctionModule(14 - i, 8, getBit(bits, i)); + for (let i = 0; i < 8; i++) + this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i)); + for (let i = 8; i < 15; i++) + this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i)); + this.setFunctionModule(8, this.size - 8, true); + } + // Draws two copies of the version bits (with its own error correction code), + // based on this object's version field, iff 7 <= version <= 40. + drawVersion() { + if (this.version < 7) + return; + let rem = this.version; + for (let i = 0; i < 12; i++) + rem = rem << 1 ^ (rem >>> 11) * 7973; + const bits = this.version << 12 | rem; + assert(bits >>> 18 == 0); + for (let i = 0; i < 18; i++) { + const color = getBit(bits, i); + const a = this.size - 11 + i % 3; + const b = Math.floor(i / 3); + this.setFunctionModule(a, b, color); + this.setFunctionModule(b, a, color); + } + } + // Draws a 9*9 finder pattern including the border separator, + // with the center module at (x, y). Modules can be out of bounds. + drawFinderPattern(x, y) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + const xx = x + dx; + const yy = y + dy; + if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size) + this.setFunctionModule(xx, yy, dist != 2 && dist != 4); + } + } + } + // Draws a 5*5 alignment pattern, with the center module + // at (x, y). All modules must be in bounds. + drawAlignmentPattern(x, y) { + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) + this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1); + } + } + // Sets the color of a module and marks it as a function module. + // Only used by the constructor. Coordinates must be in bounds. + setFunctionModule(x, y, isDark) { + this.modules[y][x] = isDark; + this.isFunction[y][x] = true; + } + /*-- Private helper methods for constructor: Codewords and masking --*/ + // Returns a new byte string representing the given data with the appropriate error correction + // codewords appended to it, based on this object's version and error correction level. + addEccAndInterleave(data) { + const ver = this.version; + const ecl = this.errorCorrectionLevel; + if (data.length != _QrCode.getNumDataCodewords(ver, ecl)) + throw new RangeError("Invalid argument"); + const numBlocks = _QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + const blockEccLen = _QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver]; + const rawCodewords = Math.floor(_QrCode.getNumRawDataModules(ver) / 8); + const numShortBlocks = numBlocks - rawCodewords % numBlocks; + const shortBlockLen = Math.floor(rawCodewords / numBlocks); + let blocks = []; + const rsDiv = _QrCode.reedSolomonComputeDivisor(blockEccLen); + for (let i = 0, k = 0; i < numBlocks; i++) { + let dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)); + k += dat.length; + const ecc = _QrCode.reedSolomonComputeRemainder(dat, rsDiv); + if (i < numShortBlocks) + dat.push(0); + blocks.push(dat.concat(ecc)); + } + let result = []; + for (let i = 0; i < blocks[0].length; i++) { + blocks.forEach((block, j) => { + if (i != shortBlockLen - blockEccLen || j >= numShortBlocks) + result.push(block[i]); + }); + } + assert(result.length == rawCodewords); + return result; + } + // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire + // data area of this QR Code. Function modules need to be marked off before this is called. + drawCodewords(data) { + if (data.length != Math.floor(_QrCode.getNumRawDataModules(this.version) / 8)) + throw new RangeError("Invalid argument"); + let i = 0; + for (let right = this.size - 1; right >= 1; right -= 2) { + if (right == 6) + right = 5; + for (let vert = 0; vert < this.size; vert++) { + for (let j = 0; j < 2; j++) { + const x = right - j; + const upward = (right + 1 & 2) == 0; + const y = upward ? this.size - 1 - vert : vert; + if (!this.isFunction[y][x] && i < data.length * 8) { + this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7)); + i++; + } + } + } + } + assert(i == data.length * 8); + } + // XORs the codeword modules in this QR Code with the given mask pattern. + // The function modules must be marked and the codeword bits must be drawn + // before masking. Due to the arithmetic of XOR, calling applyMask() with + // the same mask value a second time will undo the mask. A final well-formed + // QR Code needs exactly one (not zero, two, etc.) mask applied. + applyMask(mask) { + if (mask < 0 || mask > 7) + throw new RangeError("Mask value out of range"); + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + let invert; + switch (mask) { + case 0: + invert = (x + y) % 2 == 0; + break; + case 1: + invert = y % 2 == 0; + break; + case 2: + invert = x % 3 == 0; + break; + case 3: + invert = (x + y) % 3 == 0; + break; + case 4: + invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0; + break; + case 5: + invert = x * y % 2 + x * y % 3 == 0; + break; + case 6: + invert = (x * y % 2 + x * y % 3) % 2 == 0; + break; + case 7: + invert = ((x + y) % 2 + x * y % 3) % 2 == 0; + break; + default: + throw new Error("Unreachable"); + } + if (!this.isFunction[y][x] && invert) + this.modules[y][x] = !this.modules[y][x]; + } + } + } + // Calculates and returns the penalty score based on state of this QR Code's current modules. + // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. + getPenaltyScore() { + let result = 0; + for (let y = 0; y < this.size; y++) { + let runColor = false; + let runX = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let x = 0; x < this.size; x++) { + if (this.modules[y][x] == runColor) { + runX++; + if (runX == 5) + result += _QrCode.PENALTY_N1; + else if (runX > 5) + result++; + } else { + this.finderPenaltyAddHistory(runX, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * _QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runX = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * _QrCode.PENALTY_N3; + } + for (let x = 0; x < this.size; x++) { + let runColor = false; + let runY = 0; + let runHistory = [0, 0, 0, 0, 0, 0, 0]; + for (let y = 0; y < this.size; y++) { + if (this.modules[y][x] == runColor) { + runY++; + if (runY == 5) + result += _QrCode.PENALTY_N1; + else if (runY > 5) + result++; + } else { + this.finderPenaltyAddHistory(runY, runHistory); + if (!runColor) + result += this.finderPenaltyCountPatterns(runHistory) * _QrCode.PENALTY_N3; + runColor = this.modules[y][x]; + runY = 1; + } + } + result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * _QrCode.PENALTY_N3; + } + for (let y = 0; y < this.size - 1; y++) { + for (let x = 0; x < this.size - 1; x++) { + const color = this.modules[y][x]; + if (color == this.modules[y][x + 1] && color == this.modules[y + 1][x] && color == this.modules[y + 1][x + 1]) + result += _QrCode.PENALTY_N2; + } + } + let dark = 0; + for (const row of this.modules) + dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark); + const total = this.size * this.size; + const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1; + assert(0 <= k && k <= 9); + result += k * _QrCode.PENALTY_N4; + assert(0 <= result && result <= 2568888); + return result; + } + /*-- Private helper functions --*/ + // Returns an ascending list of positions of alignment patterns for this version number. + // Each position is in the range [0,177), and are used on both the x and y axes. + // This could be implemented as lookup table of 40 variable-length lists of integers. + getAlignmentPatternPositions() { + if (this.version == 1) + return []; + else { + const numAlign = Math.floor(this.version / 7) + 2; + const step = this.version == 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2; + let result = [6]; + for (let pos = this.size - 7; result.length < numAlign; pos -= step) + result.splice(1, 0, pos); + return result; + } + } + // Returns the number of data bits that can be stored in a QR Code of the given version number, after + // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. + // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. + static getNumRawDataModules(ver) { + if (ver < _QrCode.MIN_VERSION || ver > _QrCode.MAX_VERSION) + throw new RangeError("Version number out of range"); + let result = (16 * ver + 128) * ver + 64; + if (ver >= 2) { + const numAlign = Math.floor(ver / 7) + 2; + result -= (25 * numAlign - 10) * numAlign - 55; + if (ver >= 7) + result -= 36; + } + assert(208 <= result && result <= 29648); + return result; + } + // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any + // QR Code of the given version number and error correction level, with remainder bits discarded. + // This stateless pure function could be implemented as a (40*4)-cell lookup table. + static getNumDataCodewords(ver, ecl) { + return Math.floor(_QrCode.getNumRawDataModules(ver) / 8) - _QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * _QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]; + } + // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be + // implemented as a lookup table over all possible parameter values, instead of as an algorithm. + static reedSolomonComputeDivisor(degree) { + if (degree < 1 || degree > 255) + throw new RangeError("Degree out of range"); + let result = []; + for (let i = 0; i < degree - 1; i++) + result.push(0); + result.push(1); + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < result.length; j++) { + result[j] = _QrCode.reedSolomonMultiply(result[j], root); + if (j + 1 < result.length) + result[j] ^= result[j + 1]; + } + root = _QrCode.reedSolomonMultiply(root, 2); + } + return result; + } + // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. + static reedSolomonComputeRemainder(data, divisor) { + let result = divisor.map((_) => 0); + for (const b of data) { + const factor = b ^ result.shift(); + result.push(0); + divisor.forEach((coef, i) => result[i] ^= _QrCode.reedSolomonMultiply(coef, factor)); + } + return result; + } + // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result + // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8. + static reedSolomonMultiply(x, y) { + if (x >>> 8 != 0 || y >>> 8 != 0) + throw new RangeError("Byte out of range"); + let z = 0; + for (let i = 7; i >= 0; i--) { + z = z << 1 ^ (z >>> 7) * 285; + z ^= (y >>> i & 1) * x; + } + assert(z >>> 8 == 0); + return z; + } + // Can only be called immediately after a light run is added, and + // returns either 0, 1, or 2. A helper function for getPenaltyScore(). + finderPenaltyCountPatterns(runHistory) { + const n = runHistory[1]; + assert(n <= this.size * 3); + const core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n; + return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0); + } + // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). + finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) { + if (currentRunColor) { + this.finderPenaltyAddHistory(currentRunLength, runHistory); + currentRunLength = 0; + } + currentRunLength += this.size; + this.finderPenaltyAddHistory(currentRunLength, runHistory); + return this.finderPenaltyCountPatterns(runHistory); + } + // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). + finderPenaltyAddHistory(currentRunLength, runHistory) { + if (runHistory[0] == 0) + currentRunLength += this.size; + runHistory.pop(); + runHistory.unshift(currentRunLength); + } + }; + /*-- Constants and tables --*/ + // The minimum version number supported in the QR Code Model 2 standard. + _QrCode.MIN_VERSION = 1; + // The maximum version number supported in the QR Code Model 2 standard. + _QrCode.MAX_VERSION = 40; + // For use in getPenaltyScore(), when evaluating which mask is best. + _QrCode.PENALTY_N1 = 3; + _QrCode.PENALTY_N2 = 3; + _QrCode.PENALTY_N3 = 40; + _QrCode.PENALTY_N4 = 10; + _QrCode.ECC_CODEWORDS_PER_BLOCK = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // Low + [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], + // Medium + [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], + // Quartile + [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30] + // High + ]; + _QrCode.NUM_ERROR_CORRECTION_BLOCKS = [ + // Version: (note that index 0 is for padding, and is set to an illegal value) + //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level + [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], + // Low + [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], + // Medium + [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], + // Quartile + [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81] + // High + ]; + let QrCode = _QrCode; + qrcodegen2.QrCode = _QrCode; + function appendBits(val, len, bb) { + if (len < 0 || len > 31 || val >>> len != 0) + throw new RangeError("Value out of range"); + for (let i = len - 1; i >= 0; i--) + bb.push(val >>> i & 1); + } + function getBit(x, i) { + return (x >>> i & 1) != 0; + } + function assert(cond) { + if (!cond) + throw new Error("Assertion error"); + } + const _QrSegment = class _QrSegment { + /*-- Constructor (low level) and fields --*/ + // Creates a new QR Code segment with the given attributes and data. + // The character count (numChars) must agree with the mode and the bit buffer length, + // but the constraint isn't checked. The given bit buffer is cloned and stored. + constructor(mode, numChars, bitData) { + this.mode = mode; + this.numChars = numChars; + this.bitData = bitData; + if (numChars < 0) + throw new RangeError("Invalid argument"); + this.bitData = bitData.slice(); + } + /*-- Static factory functions (mid level) --*/ + // Returns a segment representing the given binary data encoded in + // byte mode. All input byte arrays are acceptable. Any text string + // can be converted to UTF-8 bytes and encoded as a byte mode segment. + static makeBytes(data) { + let bb = []; + for (const b of data) + appendBits(b, 8, bb); + return new _QrSegment(_QrSegment.Mode.BYTE, data.length, bb); + } + // Returns a segment representing the given string of decimal digits encoded in numeric mode. + static makeNumeric(digits) { + if (!_QrSegment.isNumeric(digits)) + throw new RangeError("String contains non-numeric characters"); + let bb = []; + for (let i = 0; i < digits.length; ) { + const n = Math.min(digits.length - i, 3); + appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb); + i += n; + } + return new _QrSegment(_QrSegment.Mode.NUMERIC, digits.length, bb); + } + // Returns a segment representing the given text string encoded in alphanumeric mode. + // The characters allowed are: 0 to 9, A to Z (uppercase only), space, + // dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static makeAlphanumeric(text) { + if (!_QrSegment.isAlphanumeric(text)) + throw new RangeError("String contains unencodable characters in alphanumeric mode"); + let bb = []; + let i; + for (i = 0; i + 2 <= text.length; i += 2) { + let temp = _QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45; + temp += _QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1)); + appendBits(temp, 11, bb); + } + if (i < text.length) + appendBits(_QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb); + return new _QrSegment(_QrSegment.Mode.ALPHANUMERIC, text.length, bb); + } + // Returns a new mutable list of zero or more segments to represent the given Unicode text string. + // The result may use various segment modes and switch modes to optimize the length of the bit stream. + static makeSegments(text) { + if (text == "") + return []; + else if (_QrSegment.isNumeric(text)) + return [_QrSegment.makeNumeric(text)]; + else if (_QrSegment.isAlphanumeric(text)) + return [_QrSegment.makeAlphanumeric(text)]; + else + return [_QrSegment.makeBytes(_QrSegment.toUtf8ByteArray(text))]; + } + // Returns a segment representing an Extended Channel Interpretation + // (ECI) designator with the given assignment value. + static makeEci(assignVal) { + let bb = []; + if (assignVal < 0) + throw new RangeError("ECI assignment value out of range"); + else if (assignVal < 1 << 7) + appendBits(assignVal, 8, bb); + else if (assignVal < 1 << 14) { + appendBits(2, 2, bb); + appendBits(assignVal, 14, bb); + } else if (assignVal < 1e6) { + appendBits(6, 3, bb); + appendBits(assignVal, 21, bb); + } else + throw new RangeError("ECI assignment value out of range"); + return new _QrSegment(_QrSegment.Mode.ECI, 0, bb); + } + // Tests whether the given string can be encoded as a segment in numeric mode. + // A string is encodable iff each character is in the range 0 to 9. + static isNumeric(text) { + return _QrSegment.NUMERIC_REGEX.test(text); + } + // Tests whether the given string can be encoded as a segment in alphanumeric mode. + // A string is encodable iff each character is in the following set: 0 to 9, A to Z + // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. + static isAlphanumeric(text) { + return _QrSegment.ALPHANUMERIC_REGEX.test(text); + } + /*-- Methods --*/ + // Returns a new copy of the data bits of this segment. + getData() { + return this.bitData.slice(); + } + // (Package-private) Calculates and returns the number of bits needed to encode the given segments at + // the given version. The result is infinity if a segment has too many characters to fit its length field. + static getTotalBits(segs, version) { + let result = 0; + for (const seg of segs) { + const ccbits = seg.mode.numCharCountBits(version); + if (seg.numChars >= 1 << ccbits) + return Infinity; + result += 4 + ccbits + seg.bitData.length; + } + return result; + } + // Returns a new array of bytes representing the given string encoded in UTF-8. + static toUtf8ByteArray(str) { + str = encodeURI(str); + let result = []; + for (let i = 0; i < str.length; i++) { + if (str.charAt(i) != "%") + result.push(str.charCodeAt(i)); + else { + result.push(parseInt(str.substring(i + 1, i + 3), 16)); + i += 2; + } + } + return result; + } + }; + /*-- Constants --*/ + // Describes precisely all strings that are encodable in numeric mode. + _QrSegment.NUMERIC_REGEX = /^[0-9]*$/; + // Describes precisely all strings that are encodable in alphanumeric mode. + _QrSegment.ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/; + // The set of all legal characters in alphanumeric mode, + // where each character value maps to the index in the string. + _QrSegment.ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + let QrSegment = _QrSegment; + qrcodegen2.QrSegment = _QrSegment; +})(qrcodegen || (qrcodegen = {})); +((qrcodegen2) => { + let QrCode; + ((QrCode2) => { + const _Ecc = class _Ecc { + // The QR Code can tolerate about 30% erroneous codewords + /*-- Constructor and fields --*/ + constructor(ordinal, formatBits) { + this.ordinal = ordinal; + this.formatBits = formatBits; + } + }; + /*-- Constants --*/ + _Ecc.LOW = new _Ecc(0, 1); + // The QR Code can tolerate about 7% erroneous codewords + _Ecc.MEDIUM = new _Ecc(1, 0); + // The QR Code can tolerate about 15% erroneous codewords + _Ecc.QUARTILE = new _Ecc(2, 3); + // The QR Code can tolerate about 25% erroneous codewords + _Ecc.HIGH = new _Ecc(3, 2); + let Ecc = _Ecc; + QrCode2.Ecc = _Ecc; + })(QrCode = qrcodegen2.QrCode || (qrcodegen2.QrCode = {})); +})(qrcodegen || (qrcodegen = {})); +((qrcodegen2) => { + let QrSegment; + ((QrSegment2) => { + const _Mode = class _Mode { + /*-- Constructor and fields --*/ + constructor(modeBits, numBitsCharCount) { + this.modeBits = modeBits; + this.numBitsCharCount = numBitsCharCount; + } + /*-- Method --*/ + // (Package-private) Returns the bit width of the character count field for a segment in + // this mode in a QR Code at the given version number. The result is in the range [0, 16]. + numCharCountBits(ver) { + return this.numBitsCharCount[Math.floor((ver + 7) / 17)]; + } + }; + /*-- Constants --*/ + _Mode.NUMERIC = new _Mode(1, [10, 12, 14]); + _Mode.ALPHANUMERIC = new _Mode(2, [9, 11, 13]); + _Mode.BYTE = new _Mode(4, [8, 16, 16]); + _Mode.KANJI = new _Mode(8, [8, 10, 12]); + _Mode.ECI = new _Mode(7, [0, 0, 0]); + let Mode = _Mode; + QrSegment2.Mode = _Mode; + })(QrSegment = qrcodegen2.QrSegment || (qrcodegen2.QrSegment = {})); +})(qrcodegen || (qrcodegen = {})); +var qrcodegen_default = qrcodegen; + +// src/index.tsx +/** + * @license qrcode.react + * Copyright (c) Paul O'Shannessy + * SPDX-License-Identifier: ISC + */ +var ERROR_LEVEL_MAP = { + L: qrcodegen_default.QrCode.Ecc.LOW, + M: qrcodegen_default.QrCode.Ecc.MEDIUM, + Q: qrcodegen_default.QrCode.Ecc.QUARTILE, + H: qrcodegen_default.QrCode.Ecc.HIGH +}; +var DEFAULT_SIZE = 128; +var DEFAULT_LEVEL = "L"; +var DEFAULT_BGCOLOR = "#FFFFFF"; +var DEFAULT_FGCOLOR = "#000000"; +var DEFAULT_INCLUDEMARGIN = false; +var DEFAULT_MINVERSION = 1; +var SPEC_MARGIN_SIZE = 4; +var DEFAULT_MARGIN_SIZE = 0; +var DEFAULT_IMG_SCALE = 0.1; +function generatePath(modules, margin = 0) { + const ops = []; + modules.forEach(function(row, y) { + let start = null; + row.forEach(function(cell, x) { + if (!cell && start !== null) { + ops.push( + `M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z` + ); + start = null; + return; + } + if (x === row.length - 1) { + if (!cell) { + return; + } + if (start === null) { + ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); + } else { + ops.push( + `M${start + margin},${y + margin} h${x + 1 - start}v1H${start + margin}z` + ); + } + return; + } + if (cell && start === null) { + start = x; + } + }); + }); + return ops.join(""); +} +function excavateModules(modules, excavation) { + return modules.slice().map((row, y) => { + if (y < excavation.y || y >= excavation.y + excavation.h) { + return row; + } + return row.map((cell, x) => { + if (x < excavation.x || x >= excavation.x + excavation.w) { + return cell; + } + return false; + }); + }); +} +function getImageSettings(cells, size, margin, imageSettings) { + if (imageSettings == null) { + return null; + } + const numCells = cells.length + margin * 2; + const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE); + const scale = numCells / size; + const w = (imageSettings.width || defaultSize) * scale; + const h = (imageSettings.height || defaultSize) * scale; + const x = imageSettings.x == null ? cells.length / 2 - w / 2 : imageSettings.x * scale; + const y = imageSettings.y == null ? cells.length / 2 - h / 2 : imageSettings.y * scale; + const opacity = imageSettings.opacity == null ? 1 : imageSettings.opacity; + let excavation = null; + if (imageSettings.excavate) { + let floorX = Math.floor(x); + let floorY = Math.floor(y); + let ceilW = Math.ceil(w + x - floorX); + let ceilH = Math.ceil(h + y - floorY); + excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH }; + } + const crossOrigin = imageSettings.crossOrigin; + return { x, y, h, w, excavation, opacity, crossOrigin }; +} +function getMarginSize(includeMargin, marginSize) { + if (marginSize != null) { + return Math.max(Math.floor(marginSize), 0); + } + return includeMargin ? SPEC_MARGIN_SIZE : DEFAULT_MARGIN_SIZE; +} +function useQRCode({ + value, + level, + minVersion, + includeMargin, + marginSize, + imageSettings, + size, + boostLevel +}) { + let qrcode = React.useMemo(() => { + const values = Array.isArray(value) ? value : [value]; + const segments = values.reduce((accum, v) => { + accum.push(...qrcodegen_default.QrSegment.makeSegments(v)); + return accum; + }, []); + return qrcodegen_default.QrCode.encodeSegments( + segments, + ERROR_LEVEL_MAP[level], + minVersion, + void 0, + void 0, + boostLevel + ); + }, [value, level, minVersion, boostLevel]); + const { cells, margin, numCells, calculatedImageSettings } = React.useMemo(() => { + let cells2 = qrcode.getModules(); + const margin2 = getMarginSize(includeMargin, marginSize); + const numCells2 = cells2.length + margin2 * 2; + const calculatedImageSettings2 = getImageSettings( + cells2, + size, + margin2, + imageSettings + ); + return { + cells: cells2, + margin: margin2, + numCells: numCells2, + calculatedImageSettings: calculatedImageSettings2 + }; + }, [qrcode, size, imageSettings, includeMargin, marginSize]); + return { + qrcode, + margin, + cells, + numCells, + calculatedImageSettings + }; +} +var SUPPORTS_PATH2D = function() { + try { + new Path2D().addPath(new Path2D()); + } catch (e) { + return false; + } + return true; +}(); +var QRCodeCanvas = React.forwardRef( + function QRCodeCanvas2(props, forwardedRef) { + const _a = props, { + value, + size = DEFAULT_SIZE, + level = DEFAULT_LEVEL, + bgColor = DEFAULT_BGCOLOR, + fgColor = DEFAULT_FGCOLOR, + includeMargin = DEFAULT_INCLUDEMARGIN, + minVersion = DEFAULT_MINVERSION, + boostLevel, + marginSize, + imageSettings + } = _a, extraProps = __objRest(_a, [ + "value", + "size", + "level", + "bgColor", + "fgColor", + "includeMargin", + "minVersion", + "boostLevel", + "marginSize", + "imageSettings" + ]); + const _b = extraProps, { style } = _b, otherProps = __objRest(_b, ["style"]); + const imgSrc = imageSettings == null ? void 0 : imageSettings.src; + const _canvas = React.useRef(null); + const _image = React.useRef(null); + const setCanvasRef = React.useCallback( + (node) => { + _canvas.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef] + ); + const [isImgLoaded, setIsImageLoaded] = React.useState(false); + const { margin, cells, numCells, calculatedImageSettings } = useQRCode({ + value, + level, + minVersion, + boostLevel, + includeMargin, + marginSize, + imageSettings, + size + }); + React.useEffect(() => { + if (_canvas.current != null) { + const canvas = _canvas.current; + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + let cellsToDraw = cells; + const image = _image.current; + const haveImageToRender = calculatedImageSettings != null && image !== null && image.complete && image.naturalHeight !== 0 && image.naturalWidth !== 0; + if (haveImageToRender) { + if (calculatedImageSettings.excavation != null) { + cellsToDraw = excavateModules( + cells, + calculatedImageSettings.excavation + ); + } + } + const pixelRatio = window.devicePixelRatio || 1; + canvas.height = canvas.width = size * pixelRatio; + const scale = size / numCells * pixelRatio; + ctx.scale(scale, scale); + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, numCells, numCells); + ctx.fillStyle = fgColor; + if (SUPPORTS_PATH2D) { + ctx.fill(new Path2D(generatePath(cellsToDraw, margin))); + } else { + cells.forEach(function(row, rdx) { + row.forEach(function(cell, cdx) { + if (cell) { + ctx.fillRect(cdx + margin, rdx + margin, 1, 1); + } + }); + }); + } + if (calculatedImageSettings) { + ctx.globalAlpha = calculatedImageSettings.opacity; + } + if (haveImageToRender) { + ctx.drawImage( + image, + calculatedImageSettings.x + margin, + calculatedImageSettings.y + margin, + calculatedImageSettings.w, + calculatedImageSettings.h + ); + } + } + }); + React.useEffect(() => { + setIsImageLoaded(false); + }, [imgSrc]); + const canvasStyle = __spreadValues({ height: size, width: size }, style); + let img = null; + if (imgSrc != null) { + img = /* @__PURE__ */ React.createElement( + "img", + { + src: imgSrc, + key: imgSrc, + style: { display: "none" }, + onLoad: () => { + setIsImageLoaded(true); + }, + ref: _image, + crossOrigin: calculatedImageSettings == null ? void 0 : calculatedImageSettings.crossOrigin + } + ); + } + return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement( + "canvas", + __spreadValues({ + style: canvasStyle, + height: size, + width: size, + ref: setCanvasRef, + role: "img" + }, otherProps) + ), img); + } +); +QRCodeCanvas.displayName = "QRCodeCanvas"; +var QRCodeSVG = React.forwardRef( + function QRCodeSVG2(props, forwardedRef) { + const _a = props, { + value, + size = DEFAULT_SIZE, + level = DEFAULT_LEVEL, + bgColor = DEFAULT_BGCOLOR, + fgColor = DEFAULT_FGCOLOR, + includeMargin = DEFAULT_INCLUDEMARGIN, + minVersion = DEFAULT_MINVERSION, + boostLevel, + title, + marginSize, + imageSettings + } = _a, otherProps = __objRest(_a, [ + "value", + "size", + "level", + "bgColor", + "fgColor", + "includeMargin", + "minVersion", + "boostLevel", + "title", + "marginSize", + "imageSettings" + ]); + const { margin, cells, numCells, calculatedImageSettings } = useQRCode({ + value, + level, + minVersion, + boostLevel, + includeMargin, + marginSize, + imageSettings, + size + }); + let cellsToDraw = cells; + let image = null; + if (imageSettings != null && calculatedImageSettings != null) { + if (calculatedImageSettings.excavation != null) { + cellsToDraw = excavateModules( + cells, + calculatedImageSettings.excavation + ); + } + image = /* @__PURE__ */ React.createElement( + "image", + { + href: imageSettings.src, + height: calculatedImageSettings.h, + width: calculatedImageSettings.w, + x: calculatedImageSettings.x + margin, + y: calculatedImageSettings.y + margin, + preserveAspectRatio: "none", + opacity: calculatedImageSettings.opacity, + crossOrigin: calculatedImageSettings.crossOrigin + } + ); + } + const fgPath = generatePath(cellsToDraw, margin); + return /* @__PURE__ */ React.createElement( + "svg", + __spreadValues({ + height: size, + width: size, + viewBox: `0 0 ${numCells} ${numCells}`, + ref: forwardedRef, + role: "img" + }, otherProps), + !!title && /* @__PURE__ */ React.createElement("title", null, title), + /* @__PURE__ */ React.createElement( + "path", + { + fill: bgColor, + d: `M0,0 h${numCells}v${numCells}H0z`, + shapeRendering: "crispEdges" + } + ), + /* @__PURE__ */ React.createElement("path", { fill: fgColor, d: fgPath, shapeRendering: "crispEdges" }), + image + ); + } +); +QRCodeSVG.displayName = "QRCodeSVG"; +export { + QRCodeCanvas, + QRCodeSVG +}; diff --git a/web/lib/popiart-api.ts b/web/lib/popiart-api.ts index 1a605fd..3b1118b 100644 --- a/web/lib/popiart-api.ts +++ b/web/lib/popiart-api.ts @@ -29,6 +29,9 @@ export type AuthSessionView = { user: PopiartUser; session_key?: string; upstream_key_masked?: string; + gateway_user_id?: number; + gateway_access_token_masked?: string; + gateway_bound: boolean; }; export type Skill = { @@ -84,6 +87,149 @@ export type BudgetUsage = { }; }; +export type Project = { + id: string; + name: string; +}; + +export type ProjectListResponse = { + items: Project[]; + total: number; + limit: number; + offset: number; +}; + +export type BillingPlan = { + id: string; + product_type: string; + badge: string; + name: string; + price: string; + cadence: string; + summary: string; + features: string[]; + cta: string; + highlight?: boolean; +}; + +export type BillingCatalog = { + plans: BillingPlan[]; + capabilities: { + subscription_status: boolean; + credit_balance: boolean; + invoices: boolean; + checkout: boolean; + }; + source: string; + note?: string; +}; + +export type BillingSubscription = { + billing_preference: string; + subscriptions: Array<{ + subscription?: { + id?: number; + status?: string; + amount_total?: number; + amount_used?: number; + start_time?: number; + end_time?: number; + next_reset_time?: number; + member_level?: string; + }; + }>; + all_subscriptions: Array>; + subscription_points: Record< + string, + { + available_points?: number; + total_points?: number; + used_points?: number; + } + >; +}; + +export type BillingCredits = { + wallets: Array<{ + id: number; + source_type: string; + points: number; + points_total: number; + expire_at: number; + status: string; + priority: number; + origin_currency: string; + origin_amount: number; + }>; + balance: number; +}; + +export type BillingSubscriptionPlan = { + plan: { + id: number; + title: string; + subtitle: string; + description: string; + price_amount: number; + currency: string; + price_amount_cny?: number; + price_amount_usd?: number; + points_amount: number; + duration_unit: string; + duration_value: number; + recommended?: boolean; + member_level?: string; + total_amount?: number; + quota_reset_period?: string; + }; +}; + +export type BillingSubscriptionPlans = { + items: BillingSubscriptionPlan[]; +}; + +export type BillingPointsPackage = { + id: number; + name: string; + currency: string; + price_amount: number; + points_amount: number; + bonus_points: number; + enabled: boolean; +}; + +export type BillingPointsPackages = { + items: BillingPointsPackage[]; +}; + +export type BillingCheckoutResult = { + message?: string; + trade_no?: string; + url?: string; + code_url?: string; + data?: Record; +}; + +export type BillingOrderPage = { + page: number; + page_size: number; + total: number; + items: Array>; +}; + +export type BillingInvoices = { + subscription_orders: BillingOrderPage; + point_orders: BillingOrderPage; +}; + +export type GatewayBindResponse = { + gateway_user_id: number; + gateway_username?: string; + gateway_display_name?: string; + gateway_email?: string; + gateway_bound: boolean; +}; + export class PopiartApiError extends Error { status: number; code?: string; @@ -221,3 +367,70 @@ export async function getBudgetUsage() { } return popiartRequest("/budget/usage", { token }); } + +export async function getProjects() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/projects", { token }); +} + +export async function getBillingCatalog() { + return popiartRequest("/billing/catalog"); +} + +export async function getBillingSubscription() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/billing/subscription", { token }); +} + +export async function getBillingCredits() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/billing/credits", { token }); +} + +export async function getBillingSubscriptionPlans() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/billing/subscription/plans", { token }); +} + +export async function getBillingPointsPackages() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/billing/points/packages", { token }); +} + +export async function getBillingInvoices(options?: { + keyword?: string; + page?: number; + pageSize?: number; +}) { + const token = await getSessionToken(); + if (!token) { + return null; + } + const query = new URLSearchParams(); + if (options?.keyword) { + query.set("keyword", options.keyword); + } + if (options?.page && options.page > 0) { + query.set("p", String(options.page)); + } + if (options?.pageSize && options.pageSize > 0) { + query.set("page_size", String(options.pageSize)); + } + const suffix = query.toString(); + return popiartRequest(`/billing/invoices${suffix ? `?${suffix}` : ""}`, { token }); +}