From 3fdd487e416daf4d3b9c498cd9808888f281c104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E5=8F=8A?= <522caiji@gmail.com> Date: Thu, 8 Oct 2026 00:25:34 +0800 Subject: [PATCH] feat(qoder): claim the global daily credit campaign Qoder Global uses the same campaign check-in as CN, including the existing automatic schedule. --- README.md | 2 +- README_EN.md | 2 +- changelog/unreleased/qoder-global-checkin.md | 7 ++++++ internal/api/checkin_test.go | 25 ++++++++++++++++---- internal/providers/registry.go | 1 + internal/runtime/checkin_integration_test.go | 4 ++-- internal/store/checkin_test.go | 17 ++++++++++--- worker/NOTICE | 11 +++++---- worker/src/checkin.mjs | 1 + worker/test/checkin.test.mjs | 15 +++++++++++- 10 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 changelog/unreleased/qoder-global-checkin.md diff --git a/README.md b/README.md index 26933a16..5b1a0049 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Go 网关统一鉴权、调度和记录请求。Qoder 每个账号使用独立 N 账号、模型、接入和日志都在同一个 Web 控制台里管理:就绪状态和额度一目了然,Access 页可以直接复制 Base URL 并做一次快速验证。 -在「账号」卡片直接操作 WorkBuddy 与 Qoder 国内版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。Qoder 国际版不显示签到,国内版活动关闭时只记录跳过;真实账号签到验收仍待完成。 +在「账号」卡片直接操作 WorkBuddy 与 Qoder 国内版、国际版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。活动关闭时只记录跳过;真实账号签到验收仍待完成。 ## 使用限制 diff --git a/README_EN.md b/README_EN.md index 761c6c56..aac500d1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -69,7 +69,7 @@ The Go gateway handles authentication, routing, and request logging. Each Qoder Accounts, models, access, and logs all live in one web console: readiness and quota are visible at a glance, and the Access page lets you copy the Base URL and run a quick check. -Account cards provide WorkBuddy and Qoder CN check-in actions and automatic check-in switches (off by default), with history under More → Check-in records; no separate check-in page is needed. Set each provider's default time under System → Automatic check-in, then choose inheritance or an override in Edit account. Default changes apply to inheriting accounts immediately. Existing WorkBuddy accounts retain their previous times until switched to inheritance. Qoder Global has no check-in controls; inactive CN campaigns are skipped. Live-account check-in acceptance is still pending. +Account cards provide WorkBuddy, Qoder CN, and Qoder Global check-in actions and automatic check-in switches (off by default), with history under More → Check-in records; no separate check-in page is needed. Set each provider's default time under System → Automatic check-in, then choose inheritance or an override in Edit account. Default changes apply to inheriting accounts immediately. Existing WorkBuddy accounts retain their previous times until switched to inheritance. Inactive campaigns are skipped. Live-account check-in acceptance is still pending. ## Limitations diff --git a/changelog/unreleased/qoder-global-checkin.md b/changelog/unreleased/qoder-global-checkin.md new file mode 100644 index 00000000..350e27e4 --- /dev/null +++ b/changelog/unreleased/qoder-global-checkin.md @@ -0,0 +1,7 @@ +### English + +- Qoder Global accounts can claim the same daily credit campaign as Qoder CN, including automatic check-in. It stays off until the account turns it on. + +### 中文 + +- Qoder 国际版账号可以领取和国内版相同的每日积分活动,并沿用现有自动签到。账号未开启前不会自动签。 diff --git a/internal/api/checkin_test.go b/internal/api/checkin_test.go index ddb43400..9851778e 100644 --- a/internal/api/checkin_test.go +++ b/internal/api/checkin_test.go @@ -34,7 +34,7 @@ func TestUnifiedCheckinSettingsAndRoutes(t *testing.T) { if !bytes.Contains(response.Body.Bytes(), []byte(`"qoder":"10:30"`)) || !bytes.Contains(response.Body.Bytes(), []byte(`"workbuddy":"12:00"`)) { t.Fatal(response.Body.String()) } - for _, body := range []string{`{"checkin_times":{"devin":"10:00"}}`, `{"checkin_times":{"qoder":"25:00"}}`, `{"checkin_times":{"qoder":"9:00"}}`, `{"checkin_times":{"workbuddy":"10:00"},"workbuddy_checkin_time":"11:00"}`} { + for _, body := range []string{`{"checkin_times":{"devin":"10:00"}}`, `{"checkin_times":{"qoder":"25:00"}}`, `{"checkin_times":{"qoder":"9:00"}}`, `{"checkin_times":{"workbuddy":"10:00"},"workbuddy_checkin_time":"11:00"}`} { if response := request(http.MethodPatch, "/api/system/settings", body, "test-key"); response.Code != http.StatusBadRequest { t.Fatalf("invalid settings accepted: %s", body) } @@ -69,11 +69,26 @@ func TestUnifiedCheckinSettingsAndRoutes(t *testing.T) { if err != nil || stored.CheckinTime != "" { t.Fatalf("reset=%+v err=%v", stored, err) } - global, err := server.Manager.Store().Create(context.Background(), accounts.CreateAccount{Name: "global", Provider: "qoder", Region: "global"}) - if err != nil { + response = request(http.MethodPost, "/api/accounts", `{"name":"Global","provider":"qoder","region":"global","auto_checkin":true}`, "test-key") + if response.Code != http.StatusCreated { + t.Fatalf("global create: %d %s", response.Code, response.Body.String()) + } + var global accounts.Account + if err := json.Unmarshal(response.Body.Bytes(), &global); err != nil { t.Fatal(err) } - if response := request(http.MethodPost, "/api/accounts/"+global.ID+"/checkin", "{}", "test-key"); response.Code == http.StatusOK { - t.Fatal("international account allowed") + if !global.AutoCheckin { + t.Fatalf("global account=%+v", global) + } + globalPath := "/api/accounts/" + global.ID + if response := request(http.MethodGet, globalPath+"/checkins", "", "test-key"); response.Code != http.StatusOK { + t.Fatalf("global history: %d %s", response.Code, response.Body.String()) + } + response = request(http.MethodPost, globalPath+"/checkin", "{}", "test-key") + if response.Code == http.StatusOK || bytes.Contains(response.Body.Bytes(), []byte("not available")) { + t.Fatalf("global check-in was rejected as unsupported: %d %s", response.Code, response.Body.String()) + } + if !bytes.Contains(response.Body.Bytes(), []byte("disabled")) { + t.Fatalf("global check-in: %d %s", response.Code, response.Body.String()) } } diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 05a8d5a7..b6ed4435 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -101,6 +101,7 @@ var Qoder = ProviderDescriptor{ ID: "global", Label: "Global", ChatBase: "https://api1.qoder.sh", BillingBase: "https://openapi.qoder.sh", AuthBase: "https://qoder.sh", DefaultDomain: "qoder.global", + Checkin: &CheckinPolicy{Timezone: "Asia/Shanghai"}, }, { ID: "cn", Label: "CN", ChatBase: "https://gateway.qoder.com.cn", diff --git a/internal/runtime/checkin_integration_test.go b/internal/runtime/checkin_integration_test.go index c1affba8..92014644 100644 --- a/internal/runtime/checkin_integration_test.go +++ b/internal/runtime/checkin_integration_test.go @@ -130,11 +130,11 @@ func TestCheckinRejectsDisabledAndUnsupportedAccounts(t *testing.T) { if _, err := manager.CheckinAccount(context.Background(), account.ID); err == nil { t.Fatal("disabled account accepted") } - global, err := manager.store.Create(context.Background(), accounts.CreateAccount{Name: "global", Provider: "qoder", Region: "global", Enabled: true}) + unsupported, err := manager.store.Create(context.Background(), accounts.CreateAccount{Name: "devin", Provider: "devin", Region: "global", Enabled: true}) if err != nil { t.Fatal(err) } - if _, err := manager.CheckinAccount(context.Background(), global.ID); !errors.Is(err, providers.ErrUnsupported) { + if _, err := manager.CheckinAccount(context.Background(), unsupported.ID); !errors.Is(err, providers.ErrUnsupported) { t.Fatalf("err=%v", err) } } diff --git a/internal/store/checkin_test.go b/internal/store/checkin_test.go index d95e1771..618ca916 100644 --- a/internal/store/checkin_test.go +++ b/internal/store/checkin_test.go @@ -67,8 +67,8 @@ func TestProviderCheckinRejectsUnsupportedRegionsAndInvalidTimes(t *testing.T) { } defer store.Close() for _, input := range []accounts.CreateAccount{ - {Name: "global", Provider: "qoder", Region: "global", AutoCheckin: boolPtr(true)}, {Name: "unsupported", Provider: "devin", Region: "global", CheckinTime: "10:00"}, + {Name: "unsupported", Provider: "devin", Region: "global", AutoCheckin: boolPtr(true)}, {Name: "invalid", Provider: "qoder", Region: "cn", CheckinTime: "9:00"}, {Name: "invalid", Provider: "qoder", Region: "cn", CheckinTime: "24:00"}, } { @@ -76,11 +76,22 @@ func TestProviderCheckinRejectsUnsupportedRegionsAndInvalidTimes(t *testing.T) { t.Fatalf("accepted %+v", input) } } - account, err := store.Create(ctx, accounts.CreateAccount{Name: "global", Provider: "qoder", Region: "global"}) + account, err := store.Create(ctx, accounts.CreateAccount{Name: "global", Provider: "qoder", Region: "global", AutoCheckin: boolPtr(true)}) + if err != nil || !account.AutoCheckin { + t.Fatalf("global check-in rejected: %+v err=%v", account, err) + } + off := false + if err := store.Update(ctx, account.ID, accounts.UpdateAccount{AutoCheckin: &off}); err != nil { + t.Fatal(err) + } + if err := store.Update(ctx, account.ID, accounts.UpdateAccount{AutoCheckin: boolPtr(true)}); err != nil { + t.Fatal(err) + } + devin, err := store.Create(ctx, accounts.CreateAccount{Name: "devin", Provider: "devin", Region: "global"}) if err != nil { t.Fatal(err) } - if err := store.Update(ctx, account.ID, accounts.UpdateAccount{AutoCheckin: boolPtr(true)}); err == nil { + if err := store.Update(ctx, devin.ID, accounts.UpdateAccount{AutoCheckin: boolPtr(true)}); err == nil { t.Fatal("enabled unsupported region") } } diff --git a/worker/NOTICE b/worker/NOTICE index 19f82535..91c8ad07 100644 --- a/worker/NOTICE +++ b/worker/NOTICE @@ -1,8 +1,11 @@ -Qoder CN check-in follows the official Qoder CN desktop campaign API: +Qoder check-in follows the official desktop campaign API. CN and Global use the +same paths on different hosts: GET https://openapi.qoder.com.cn/sash/api/v1/me/campaigns POST https://openapi.qoder.com.cn/sash/api/v1/me/campaigns/{campaignId}/claim + GET https://openapi.qoder.sh/sash/api/v1/me/campaigns + POST https://openapi.qoder.sh/sash/api/v1/me/campaigns/{campaignId}/claim -Observed from Qoder CN 0.3.4 (activity iframe -https://g.alicdn.com/qbase/qoder/0.0.704/growth-page/activity-iframe/activity-iframe.js). -The worker reuses the pinned AuthManager and does not copy Qoder's campaign UI. +CN was observed from Qoder CN 0.3.4. Global was observed from the Qoder desktop +client on 2026-10-07. The worker reuses the pinned AuthManager and does not copy +Qoder's campaign UI. diff --git a/worker/src/checkin.mjs b/worker/src/checkin.mjs index 5728eb01..11eaf9c4 100644 --- a/worker/src/checkin.mjs +++ b/worker/src/checkin.mjs @@ -6,6 +6,7 @@ import path from "node:path"; const endpoints = { cn: { base: "https://openapi.qoder.com.cn", origin: "https://qoder.com.cn" }, + global: { base: "https://openapi.qoder.sh", origin: "https://qoder.sh" }, }; const campaignsPath = "/sash/api/v1/me/campaigns"; const maxResponseBytes = 65536; diff --git a/worker/test/checkin.test.mjs b/worker/test/checkin.test.mjs index 68f18345..c7253f99 100644 --- a/worker/test/checkin.test.mjs +++ b/worker/test/checkin.test.mjs @@ -170,8 +170,21 @@ test("404 list is skipped, not a protocol error", async () => { assert.equal(calls.length, 1); }); +test("global check-in uses the international campaign host", async () => { + const id = "01a0f1db-9cc0-725f-a2f4-ca596883291d"; + const { checkin, calls } = fixture([json(listed(credit({ id }), details())), json({ status: "CLAIMED" })], { region: "global" }); + assert.deepEqual(await checkin(), { status: "success", message: "签到成功 +100 积分", reward_credits: 100 }); + assert.equal(calls[0].url, "https://openapi.qoder.sh/sash/api/v1/me/campaigns"); + assert.equal(calls[0].init.method, "GET"); + assert.equal(calls[1].url, `https://openapi.qoder.sh/sash/api/v1/me/campaigns/${id}/claim`); + assert.equal(calls[1].init.method, "POST"); + assert.equal(calls[1].init.headers.Origin, "https://qoder.sh"); + assert.equal(calls[1].init.headers.Referer, "https://openapi.qoder.sh/growth-page/activity-iframe"); + assert.equal(calls[1].init.headers["Cosy-Version"], "0.3.4"); +}); + test("unsupported regions never touch auth or the network", async () => { - const { checkin, calls } = fixture([], { region: "global", getAuthManager: () => { throw new Error("unexpected auth access"); } }); + const { checkin, calls } = fixture([], { region: "eu", getAuthManager: () => { throw new Error("unexpected auth access"); } }); await assert.rejects(checkin(), /region_unsupported/); assert.equal(calls.length, 0); });