diff --git a/README.md b/README.md index 798e24ab..60800a44 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Go 网关统一鉴权、调度和记录请求。Qoder 每个账号使用独立 N 账号、模型、接入和日志都在同一个 Web 控制台里管理:就绪状态和额度一目了然,Access 页可以直接复制 Base URL 并做一次快速验证。 -在「账号」卡片直接操作 WorkBuddy 与 Qoder 国内版、国际版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。活动关闭时只记录跳过;真实账号签到验收仍待完成。 +在「账号」卡片直接操作 WorkBuddy、QwenWork 国内版/国际版与 Qoder 国内版、国际版的签到、自动签到开关(默认关闭),并从「更多 → 签到记录」查看历史,不另设签到中心。在「系统 → 自动签到」配置各供应商默认时间,「编辑账号」可选择跟随默认或自定义;修改默认时间会立即影响未覆盖的账号。已有 WorkBuddy 账号保留原时间,可在编辑账号时切回跟随默认。活动关闭时只记录跳过;真实账号签到验收仍待完成。QwenWork 的签到接口在客户端里存在,但生产网关尚未开放(404):国内版按钱包日额度核对当日免费积分,国际版只能核对总额度并记录跳过原因。 ## 使用限制 diff --git a/README_EN.md b/README_EN.md index 6a6e0357..557bddb0 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, 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. +Account cards provide WorkBuddy, QwenWork CN/Global, 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. QwenWork ships a check-in API in its desktop client, but production gateways do not route it (404): CN confirms the daily free credits from the wallet balance, while Global can only report the total quota and the skip reason. ## Limitations diff --git a/changelog/unreleased/qwenwork-daily-checkin.md b/changelog/unreleased/qwenwork-daily-checkin.md new file mode 100644 index 00000000..760ce19e --- /dev/null +++ b/changelog/unreleased/qwenwork-daily-checkin.md @@ -0,0 +1,9 @@ +### English + +- Add QwenWork CN and Global daily check-in: account cards now expose the manual action and the off-by-default automatic switch, with per-account and provider default times shared with the existing scheduler. +- QwenWork check-in tries the desktop client's `daily-check-in` claim API first, then falls back to today's server-granted free credits: CN reads the daily wallet balance, Global reports the gateway gap (claim route 404) instead of a fake success. + +### 中文 + +- 新增 QwenWork 国内版/国际版每日签到:账号卡片提供手动签到和默认关闭的自动签到开关,沿用现有的账号级/供应商级签到时间调度。 +- QwenWork 签到先调用桌面客户端的每日签到领取接口,接口未开放时改为核对当日服务端发放的免费积分:国内版读取钱包日额度,国际版明确记录网关未开放的原因,不伪造成功。 diff --git a/internal/providers/qwenwork/checkin.go b/internal/providers/qwenwork/checkin.go new file mode 100644 index 00000000..64c73f75 --- /dev/null +++ b/internal/providers/qwenwork/checkin.go @@ -0,0 +1,318 @@ +package qwenwork + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/caigee-cmd/cli2api/internal/providers" +) + +// Daily reward paths. The desktop bundles (global 1.0.4, CN 1.4.0) call the +// growth-card endpoints below; production gateways may answer 404 while the +// campaign is closed, so Checkin falls back to the daily-grant probe. +const ( + pathDailyCheckinStatus = "/sash/api/v1/me/daily-check-in/status" + pathDailyCheckinClaim = "/sash/api/v1/me/daily-check-in/claim" + pathWallets = "/api/v1/adapter/user/wallets" +) + +// dailyCheckinStatuses mirrors the desktop value set: CLAIMABLE, CLAIMED_TODAY, +// DISABLED, NOT_STARTED, ENDED. claimDailyCheckinResults mirrors the claim +// response: CLAIMED, ALREADY_CLAIMED. +const ( + dailyCheckinClaimable = "CLAIMABLE" + dailyCheckinClaimedToday = "CLAIMED_TODAY" + dailyCheckinDisabled = "DISABLED" + dailyCheckinNotStarted = "NOT_STARTED" + dailyCheckinEnded = "ENDED" + + dailyClaimDone = "CLAIMED" + dailyClaimAlready = "ALREADY_CLAIMED" + + // checkOK is the gateway envelope's success code. + checkOK = "ok" + + // growthCardUserAgent is the UA the desktop growth card sends, distinct + // from the gateway's qoderwork/ agent. + growthCardUserAgent = "QoderWork" + + // checkinTimeout bounds the whole check-in round trip. + checkinTimeout = 30 * time.Second +) + +// dailyCheckinDocument is the growth-card status document. The desktop client +// reads the same fields from data.status; reward and streak values are kept as +// pointers because CLAIMED_TODAY may omit them. +type dailyCheckinDocument struct { + Code string `json:"code"` + Data struct { + Status string `json:"status"` + RewardCredits *float64 `json:"rewardCredits"` + NextClaimAt *int64 `json:"nextClaimAt"` + CurrentStreakDays *int `json:"currentStreakDays"` + TotalClaimDays *int `json:"totalClaimDays"` + TotalRewardCredits *float64 `json:"totalRewardCredits"` + LastClaimedAt *int64 `json:"lastClaimedAt"` + RewardExpiresAt *int64 `json:"rewardExpiresAt"` + } `json:"data"` +} + +// dailyClaimDocument is the claim response document. +type dailyClaimDocument struct { + Code string `json:"code"` + Data struct { + Result string `json:"result"` + RewardCredits *float64 `json:"rewardCredits"` + ExpiresAt *int64 `json:"expiresAt"` + } `json:"data"` +} + +// walletsDocument is the CN wallet summary. daily_credits holds the credits +// granted for the current day; the desktop notice renders the same number as +// "今日已获得 N 免费积分". +type walletsDocument struct { + Code string `json:"code"` + Data struct { + DailyCredits struct { + TotalBalance *float64 `json:"total_balance"` + } `json:"daily_credits"` + LongtermCredits struct { + TotalBalance *float64 `json:"total_balance"` + } `json:"longterm_credits"` + } `json:"data"` +} + +// Checkin confirms the account's daily free credits. +// +// The QwenWork desktop client ships a growth-card check-in (status + claim) +// gated by the remote `operations/announcement:credits-growth-card` config. The +// production gateways do not route that prefix, so the claim path is tried +// first and, when the route is absent, the daily grant is confirmed from the +// region's own data: CN exposes the daily wallet balance; global exposes only +// the account-context total. +func (c *Client) Checkin(ctx context.Context, accountID string) (providers.CheckinResult, error) { + credential, st, err := c.resolvedCredential(ctx, accountID) + if err != nil { + return providers.CheckinResult{}, err + } + ctx, cancel := context.WithTimeout(ctx, checkinTimeout) + defer cancel() + result, campaignHandled, err := c.claimDailyCampaign(ctx, accountID, st, credential) + if err != nil { + return providers.CheckinResult{}, err + } + if campaignHandled { + return result, nil + } + return c.confirmDailyGrant(ctx, accountID, st, credential) +} + +// claimDailyCampaign runs the growth-card status/claim flow. campaignHandled is +// false when the gateway does not route the check-in prefix, which is the +// current production state; the caller then confirms the server-side grant. +func (c *Client) claimDailyCampaign(ctx context.Context, accountID string, st site, credential Credential) (providers.CheckinResult, bool, error) { + body, status, err := c.do(ctx, accountID, http.MethodGet, st.Gateway+pathDailyCheckinStatus, nil, + func(h http.Header) { setGrowthCardHeaders(h, st, credential.AccessToken) }) + if err != nil { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: status request: %w", err) + } + switch { + case status == http.StatusUnauthorized || status == http.StatusForbidden: + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: authentication failed (HTTP %d)", status) + case status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented: + // The prefix is not deployed; confirm today's automatic grant instead. + return providers.CheckinResult{}, false, nil + case status >= 300: + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: status HTTP %d: %s", status, truncateBody(body)) + } + var document dailyCheckinDocument + if err := json.Unmarshal(body, &document); err != nil { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: invalid status document") + } + if document.Code != "" && document.Code != checkOK { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: status code=%s", document.Code) + } + state := strings.ToUpper(strings.TrimSpace(document.Data.Status)) + switch state { + case dailyCheckinClaimable: + return c.claimCampaign(ctx, accountID, st, credential) + case dailyCheckinClaimedToday: + result := providers.CheckinResult{Status: "already", Message: checkinAlreadyMessage(document)} + if document.Data.RewardCredits != nil { + result.RewardCredits = *document.Data.RewardCredits + } + return result, true, nil + case dailyCheckinDisabled: + return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, true, nil + case dailyCheckinNotStarted: + return providers.CheckinResult{Status: "skipped", Message: "签到活动未开始"}, true, nil + case dailyCheckinEnded: + return providers.CheckinResult{Status: "skipped", Message: "签到活动已结束"}, true, nil + default: + message := "签到活动状态未知" + if state != "" { + message = fmt.Sprintf("签到活动状态 %s", state) + } + return providers.CheckinResult{Status: "skipped", Message: message}, true, nil + } +} + +// claimCampaign POSTs the claim for a CLAIMABLE campaign and maps the result. +func (c *Client) claimCampaign(ctx context.Context, accountID string, st site, credential Credential) (providers.CheckinResult, bool, error) { + body, status, err := c.do(ctx, accountID, http.MethodPost, st.Gateway+pathDailyCheckinClaim, nil, + func(h http.Header) { setGrowthCardHeaders(h, st, credential.AccessToken) }) + if err != nil { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: claim request: %w", err) + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: authentication failed (HTTP %d)", status) + } + if status >= 300 { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: claim HTTP %d: %s", status, truncateBody(body)) + } + var document dailyClaimDocument + if err := json.Unmarshal(body, &document); err != nil { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: invalid claim document") + } + if document.Code != "" && document.Code != checkOK { + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: claim code=%s", document.Code) + } + reward := 0.0 + hasReward := document.Data.RewardCredits != nil + if hasReward { + reward = *document.Data.RewardCredits + } + switch strings.ToUpper(strings.TrimSpace(document.Data.Result)) { + case dailyClaimDone: + result := providers.CheckinResult{Status: "success", Message: "签到成功", RewardCredits: reward} + if hasReward { + result.Message = fmt.Sprintf("签到成功 +%g 积分", reward) + } + return result, true, nil + case dailyClaimAlready: + return providers.CheckinResult{Status: "already", Message: "今日已签到"}, true, nil + default: + return providers.CheckinResult{}, false, fmt.Errorf("qwenwork check-in: unexpected claim result %q", document.Data.Result) + } +} + +// confirmDailyGrant verifies the server-side daily free credits when the claim +// prefix is not deployed. CN exposes a per-day wallet; global exposes only the +// account total, so it reports the upstream gap instead of claiming success. +func (c *Client) confirmDailyGrant(ctx context.Context, accountID string, st site, credential Credential) (providers.CheckinResult, error) { + if st.Region == "cn" { + return c.confirmCNWallet(ctx, accountID, st, credential) + } + remaining, ok := c.dailyQuotaRemaining(ctx, accountID, st, credential) + if !ok { + return providers.CheckinResult{ + Status: "skipped", + Message: "签到接口未开放(上游 404);每日登录奖励由服务端自动发放", + }, nil + } + return providers.CheckinResult{ + Status: "skipped", + Message: fmt.Sprintf("签到接口未开放(上游 404);每日登录奖励由服务端自动发放,当前剩余额度 %g", remaining), + }, nil +} + +// confirmCNWallet reads the CN daily wallet. A positive daily balance means +// today's free credits were granted (the client copy says they refresh at +// 00:00); a zero balance can mean not granted yet or already spent, so it is +// reported as skipped rather than a failure. +func (c *Client) confirmCNWallet(ctx context.Context, accountID string, st site, credential Credential) (providers.CheckinResult, error) { + body, status, err := c.do(ctx, accountID, http.MethodGet, st.Gateway+pathWallets, nil, + func(h http.Header) { setGrowthCardHeaders(h, st, credential.AccessToken) }) + if err != nil { + return providers.CheckinResult{}, fmt.Errorf("qwenwork check-in: wallet request: %w", err) + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return providers.CheckinResult{}, fmt.Errorf("qwenwork check-in: authentication failed (HTTP %d)", status) + } + if status == http.StatusNotFound || status == http.StatusMethodNotAllowed { + return providers.CheckinResult{ + Status: "skipped", + Message: "签到接口未开放(上游 404);钱包接口不可用", + }, nil + } + if status >= 300 { + return providers.CheckinResult{}, fmt.Errorf("qwenwork check-in: wallet HTTP %d: %s", status, truncateBody(body)) + } + var document walletsDocument + if err := json.Unmarshal(body, &document); err != nil { + return providers.CheckinResult{}, fmt.Errorf("qwenwork check-in: invalid wallet document") + } + if document.Code != "" && document.Code != checkOK { + return providers.CheckinResult{}, fmt.Errorf("qwenwork check-in: wallet code=%s", document.Code) + } + daily := 0.0 + if document.Data.DailyCredits.TotalBalance != nil { + daily = *document.Data.DailyCredits.TotalBalance + } + if daily <= 0 { + return providers.CheckinResult{ + Status: "skipped", + Message: "今日每日免费积分尚未到账(每日 0 点刷新)", + }, nil + } + return providers.CheckinResult{ + Status: "already", + Message: fmt.Sprintf("每日免费积分已到账 +%g(每日 0 点刷新)", daily), + RewardCredits: daily, + }, nil +} + +// dailyQuotaRemaining reads the account-context quota for the global region, +// where no per-day wallet is exposed. +func (c *Client) dailyQuotaRemaining(ctx context.Context, accountID string, st site, credential Credential) (float64, bool) { + body, status, err := c.do(ctx, accountID, http.MethodGet, st.Gateway+pathAccountContext, nil, + func(h http.Header) { setPlainHeaders(h, st, credential.AccessToken) }) + if err != nil || status >= 300 { + return 0, false + } + var envelope struct { + Data struct { + Quota struct { + Remaining *float64 `json:"remaining"` + } `json:"quota"` + } `json:"data"` + } + if json.Unmarshal(body, &envelope) != nil || envelope.Data.Quota.Remaining == nil { + return 0, false + } + return *envelope.Data.Quota.Remaining, true +} + +// checkinAlreadyMessage renders the streak detail the growth card shows next to +// "今日已签到", keeping the persisted message short. +func checkinAlreadyMessage(document dailyCheckinDocument) string { + data := document.Data + if data.RewardCredits != nil && *data.RewardCredits > 0 { + return fmt.Sprintf("今日已签到 +%g 积分", *data.RewardCredits) + } + if data.CurrentStreakDays != nil && *data.CurrentStreakDays > 0 { + return fmt.Sprintf("今日已签到(连签 %d 天)", *data.CurrentStreakDays) + } + return "今日已签到" +} + +// setGrowthCardHeaders mirrors the desktop growth-card request identity: plain +// Bearer plus the X-QwenWork client headers and the QoderWork agent. +func setGrowthCardHeaders(h http.Header, st site, bearer string) { + h.Set("Accept", "application/json") + h.Set("Content-Type", "application/json") + h.Set("User-Agent", growthCardUserAgent) + h.Set("X-Qwenwork-Version", st.AppVersion) + h.Set("X-Qwenwork-Release-Version", st.releaseVersion()) + h.Set("X-Qwenwork-Build", st.Build) + h.Set("X-Qwenwork-Platform", "win32") + h.Set("X-Qwenwork-Arch", "x64") + h.Set("X-Qwenwork-Channel", "stable") + if bearer != "" { + h.Set("Authorization", "Bearer "+bearer) + } +} diff --git a/internal/providers/qwenwork/checkin_test.go b/internal/providers/qwenwork/checkin_test.go new file mode 100644 index 00000000..7cdd57e3 --- /dev/null +++ b/internal/providers/qwenwork/checkin_test.go @@ -0,0 +1,281 @@ +package qwenwork + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" +) + +// newCheckinClient seeds one ready credential and points the adapter at the +// handler. Region selects the CN wallet fallback versus the global path. +func newCheckinClient(t *testing.T, region string, handler http.Handler) *Client { + t.Helper() + encoded, err := readyCredential().Encode() + if err != nil { + t.Fatalf("encode credential: %v", err) + } + store := &memStore{region: region, items: map[string][]byte{"acc1": encoded}} + return newTestClient(t, store, handler) +} + +func TestCheckinClaimsCampaign(t *testing.T) { + var claimed bool + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == pathDailyCheckinStatus: + if got := r.Header.Get("Authorization"); got != "Bearer at" { + t.Errorf("status auth=%q", got) + } + if got := r.Header.Get("User-Agent"); got != growthCardUserAgent { + t.Errorf("status ua=%q", got) + } + if got := r.Header.Get("X-Qwenwork-Version"); got == "" { + t.Error("status missing client version header") + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": "ok", "data": map[string]any{ + "status": dailyCheckinClaimable, "rewardCredits": 200, + }}) + case r.Method == http.MethodPost && r.URL.Path == pathDailyCheckinClaim: + claimed = true + _ = json.NewEncoder(w).Encode(map[string]any{"code": "ok", "data": map[string]any{ + "result": dailyClaimDone, "rewardCredits": 200, + }}) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if !claimed { + t.Fatal("claim was not posted") + } + if result.Status != "success" || result.RewardCredits != 200 || !strings.Contains(result.Message, "+200") { + t.Fatalf("result=%+v", result) + } +} + +func TestCheckinCampaignAlreadyClaimed(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != pathDailyCheckinStatus { + t.Fatalf("unexpected path %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "status": dailyCheckinClaimedToday, "rewardCredits": 100, "currentStreakDays": 3, + }}) + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "already" || result.RewardCredits != 100 { + t.Fatalf("result=%+v", result) + } + if !strings.Contains(result.Message, "今日已签到") { + t.Fatalf("message=%q", result.Message) + } +} + +func TestCheckinCampaignAlreadyClaimedWithoutReward(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "status": dailyCheckinClaimedToday, "currentStreakDays": 4, + }}) + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "already" || !strings.Contains(result.Message, "连签 4 天") { + t.Fatalf("result=%+v", result) + } +} + +func TestCheckinClaimResultAlreadyClaimed(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDailyCheckinStatus: + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"status": dailyCheckinClaimable}}) + case pathDailyCheckinClaim: + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"result": dailyClaimAlready}}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "already" || result.Message != "今日已签到" { + t.Fatalf("result=%+v", result) + } +} + +func TestCheckinCampaignClosedStates(t *testing.T) { + for _, test := range []struct { + status string + message string + }{ + {dailyCheckinDisabled, "签到活动未开放"}, + {dailyCheckinNotStarted, "签到活动未开始"}, + {dailyCheckinEnded, "签到活动已结束"}, + } { + t.Run(test.status, func(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"status": test.status}}) + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "skipped" || result.Message != test.message { + t.Fatalf("result=%+v", result) + } + }) + } +} + +func TestCheckinFallsBackToCNWallet(t *testing.T) { + client := newCheckinClient(t, "cn", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDailyCheckinStatus: + w.WriteHeader(http.StatusNotFound) + case pathWallets: + if r.Header.Get("Authorization") != "Bearer at" { + t.Errorf("wallet auth=%q", r.Header.Get("Authorization")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": "ok", "data": map[string]any{ + "daily_credits": map[string]any{"total_balance": 100}, + }}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "already" || result.RewardCredits != 100 { + t.Fatalf("result=%+v", result) + } + if !strings.Contains(result.Message, "每日免费积分已到账") { + t.Fatalf("message=%q", result.Message) + } +} + +func TestCheckinCNWalletZeroIsSkipped(t *testing.T) { + client := newCheckinClient(t, "cn", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDailyCheckinStatus: + w.WriteHeader(http.StatusNotFound) + case pathWallets: + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "daily_credits": map[string]any{"total_balance": 0}, + }}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "skipped" || !strings.Contains(result.Message, "尚未到账") { + t.Fatalf("result=%+v", result) + } +} + +func TestCheckinGlobalReportsClosedCampaignWithQuota(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDailyCheckinStatus: + w.WriteHeader(http.StatusNotFound) + case pathAccountContextPath: + _ = json.NewEncoder(w).Encode(map[string]any{"code": "ok", "data": map[string]any{ + "quota": map[string]any{"remaining": 700}, + }}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + result, err := client.Checkin(context.Background(), "acc1") + if err != nil { + t.Fatal(err) + } + if result.Status != "skipped" || !strings.Contains(result.Message, "700") { + t.Fatalf("result=%+v", result) + } + if !strings.Contains(result.Message, "服务端自动发放") { + t.Fatalf("message=%q", result.Message) + } +} + +func TestCheckinAuthenticationFailure(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"code":"401","message":"invalid token"}`)) + })) + if _, err := client.Checkin(context.Background(), "acc1"); err == nil || !strings.Contains(err.Error(), "authentication failed") { + t.Fatalf("err=%v", err) + } +} + +func TestCheckinClaimHTTPError(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDailyCheckinStatus: + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"status": dailyCheckinClaimable}}) + case pathDailyCheckinClaim: + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"code":"429"}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + if _, err := client.Checkin(context.Background(), "acc1"); err == nil || !strings.Contains(err.Error(), "claim HTTP 429") { + t.Fatalf("err=%v", err) + } +} + +func TestCheckinRejectsUnknownClaimResult(t *testing.T) { + client := newCheckinClient(t, "global", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case pathDailyCheckinStatus: + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"status": dailyCheckinClaimable}}) + case pathDailyCheckinClaim: + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"result": "WAT"}}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + if _, err := client.Checkin(context.Background(), "acc1"); err == nil || !strings.Contains(err.Error(), "unexpected claim result") { + t.Fatalf("err=%v", err) + } +} + +func TestCheckinWalletAuthFailure(t *testing.T) { + client := newCheckinClient(t, "cn", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == pathDailyCheckinStatus { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"code":"403"}`)) + })) + if _, err := client.Checkin(context.Background(), "acc1"); err == nil || !strings.Contains(err.Error(), "authentication failed") { + t.Fatalf("err=%v", err) + } +} diff --git a/internal/providers/qwenwork/client.go b/internal/providers/qwenwork/client.go index edc200d9..c9a0f623 100644 --- a/internal/providers/qwenwork/client.go +++ b/internal/providers/qwenwork/client.go @@ -868,6 +868,7 @@ func (c *Client) Adapter() providers.Adapter { Models: c, Classifier: classifier{}, Prober: c, + Checkin: c, } } diff --git a/internal/providers/qwenwork/client_test.go b/internal/providers/qwenwork/client_test.go index ace43c81..b3aaadee 100644 --- a/internal/providers/qwenwork/client_test.go +++ b/internal/providers/qwenwork/client_test.go @@ -1067,14 +1067,11 @@ func TestAdapterBundle(t *testing.T) { if adapter.ID != "qwenwork" { t.Fatalf("id=%s", adapter.ID) } - for _, capability := range []string{"credential", "login", "chat", "models", "classifier", "prober"} { + for _, capability := range []string{"credential", "login", "chat", "models", "classifier", "prober", "checkin"} { if !adapter.Supports(capability) { t.Fatalf("adapter missing %s", capability) } } - if adapter.Supports("checkin") { - t.Fatal("qwenwork has no check-in") - } } // Regression: a caller that loaded the credential before an earlier refresh diff --git a/internal/providers/qwenwork/credential.go b/internal/providers/qwenwork/credential.go index eaab289b..9cf86ff5 100644 --- a/internal/providers/qwenwork/credential.go +++ b/internal/providers/qwenwork/credential.go @@ -43,6 +43,7 @@ const ( // and default model differ. Values come from the QwenWork desktop bundles // (intl 1.0.4, CN 1.0.5). type site struct { + Region string Gateway string ClientID string RedirectURI string @@ -53,6 +54,7 @@ type site struct { var sites = map[string]site{ "global": { + Region: "global", Gateway: "https://gateway.qwenwork.ai", ClientID: "cc65e5fc-05bd-4f5d-a4e8-0df19aa3d75a", RedirectURI: "qwenwork://", @@ -61,6 +63,7 @@ var sites = map[string]site{ DefaultModel: "qwork-advanced", }, "cn": { + Region: "cn", Gateway: "https://gateway.qwenwork.cn", ClientID: "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb", RedirectURI: "qwenwork-cn://", diff --git a/internal/providers/qwenwork/doc.go b/internal/providers/qwenwork/doc.go index 43cc5384..3771a14c 100644 --- a/internal/providers/qwenwork/doc.go +++ b/internal/providers/qwenwork/doc.go @@ -8,4 +8,15 @@ // independent Go implementation on cli2api's Provider Adapter surface, reusing // the COSY signing approach of internal/providers/qoder. The camelCase import // shape accepts that project's account export. +// +// Daily free credits: the desktop bundles (global 1.0.4, CN 1.4.0) call +// GET /sash/api/v1/me/daily-check-in/status, POST .../claim, and +// GET /sash/api/v1/me/{invitationRewards,invitationCode} for the growth card, +// gated by the remote `operations/announcement:credits-growth-card` config. +// Both production gateways answer 404 for every /sash path, and the config +// client logs "no remote value" for both accounts, so the campaign is not +// claimable today; the daily credits are granted server-side instead. CN +// exposes the grant through /api/v1/adapter/user/wallets (daily_credits, +// expiring at 00:00 Asia/Shanghai); the global gateway exposes only the +// account-context quota total. package qwenwork diff --git a/internal/providers/registry.go b/internal/providers/registry.go index cd1f79f1..6501cebc 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -267,6 +267,9 @@ var Zhipu = ProviderDescriptor{ // QwenWork descriptor. Protocol constants stay in internal/providers/qwenwork; // the registry only carries control-plane metadata. QwenWork is the rebranded // QoderWork desktop product: global is qwenwork.ai, cn is 千问办公 qwenwork.cn. +// Daily free credits are granted by the server at 00:00 Asia/Shanghai (CN +// wallet copy: "每日 0 点刷新"; global pricing lists the same "daily login +// bonus"), so both regions use that day boundary. var QwenWork = ProviderDescriptor{ ID: "qwenwork", Label: "QwenWork", @@ -283,11 +286,13 @@ var QwenWork = ProviderDescriptor{ ID: "global", Label: "International", ChatBase: "https://gateway.qwenwork.ai", BillingBase: "https://gateway.qwenwork.ai", AuthBase: "https://gateway.qwenwork.ai", DefaultDomain: "qwenwork.ai", + Checkin: &CheckinPolicy{Timezone: "Asia/Shanghai"}, }, { ID: "cn", Label: "CN", ChatBase: "https://gateway.qwenwork.cn", BillingBase: "https://gateway.qwenwork.cn", AuthBase: "https://gateway.qwenwork.cn", DefaultDomain: "qwenwork.cn", + Checkin: &CheckinPolicy{Timezone: "Asia/Shanghai"}, }, }, DefaultRegion: "global", diff --git a/internal/providers/registry_test.go b/internal/providers/registry_test.go index dcd7db62..2e1ac66e 100644 --- a/internal/providers/registry_test.go +++ b/internal/providers/registry_test.go @@ -24,3 +24,19 @@ func TestListIncludesEveryRegisteredDescriptor(t *testing.T) { t.Fatalf("unexpected codex descriptor: %+v", codex) } } + +func TestQwenWorkAdvertisesDailyCheckin(t *testing.T) { + for _, region := range []string{"global", "cn"} { + policy, ok := CheckinFor("qwenwork", region) + if !ok || policy == nil { + t.Fatalf("region %s: qwenwork check-in not advertised", region) + } + if policy.Timezone != "Asia/Shanghai" { + t.Fatalf("region %s: timezone=%q", region, policy.Timezone) + } + } + descriptor, ok := Get("qwenwork") + if !ok || !descriptor.SupportsCheckin() { + t.Fatal("qwenwork descriptor must support check-in") + } +}