Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Go 网关统一鉴权、调度和记录请求。Qoder 每个账号使用独立 N

账号、模型、接入和日志都在同一个 Web 控制台里管理:就绪状态和额度一目了然,Access 页可以直接复制 Base URL 并做一次快速验证。

在「账号」卡片直接操作 WorkBuddy 与 Qoder 国内版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。Qoder 国际版不显示签到,国内版活动关闭时只记录跳过;真实账号签到验收仍待完成。
在「账号」卡片直接操作 WorkBuddy 与 Qoder 国内版、国际版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。活动关闭时只记录跳过;真实账号签到验收仍待完成。

## 使用限制

Expand Down
2 changes: 1 addition & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-global-checkin.md
Original file line number Diff line number Diff line change
@@ -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 国际版账号可以领取和国内版相同的每日积分活动,并沿用现有自动签到。账号未开启前不会自动签。
25 changes: 20 additions & 5 deletions internal/api/checkin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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())
}
}
1 change: 1 addition & 0 deletions internal/providers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions internal/runtime/checkin_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
17 changes: 14 additions & 3 deletions internal/store/checkin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,31 @@ 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"},
} {
if _, err := store.Create(ctx, input); err == nil {
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")
}
}
Expand Down
11 changes: 7 additions & 4 deletions worker/NOTICE
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions worker/src/checkin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion worker/test/checkin.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading