From aa7646852ef5d7927e1e47c1dc5ade5aad8a28b5 Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:37:19 +0800 Subject: [PATCH 1/2] fix(qoder): surface check-in skip reason and keep tokens on partial renewal (#323) --- .../unreleased/qoder-checkin-diagnostics.md | 1 + internal/providers/qoder/bridge.go | 14 +++++- internal/providers/qoder/bridge_token_test.go | 41 ++++++++++++++++ internal/providers/qoder/checkin.go | 47 ++++++++++++++++++- .../providers/qoder/checkin_inprocess_test.go | 19 +++++++- 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 changelog/unreleased/qoder-checkin-diagnostics.md create mode 100644 internal/providers/qoder/bridge_token_test.go diff --git a/changelog/unreleased/qoder-checkin-diagnostics.md b/changelog/unreleased/qoder-checkin-diagnostics.md new file mode 100644 index 00000000..d1905b4d --- /dev/null +++ b/changelog/unreleased/qoder-checkin-diagnostics.md @@ -0,0 +1 @@ +- Qoder 签到:上游未返回可领取活动时,不再只显示"签到活动未开放",日志与账号卡片签到提示会带上活动数与 `actionType:claimStatus` 摘要;token 续期响应缺少 securityOauthToken / refreshToken 时保留原值,不再被清空。(#323) diff --git a/internal/providers/qoder/bridge.go b/internal/providers/qoder/bridge.go index 9effc3dd..620675b9 100644 --- a/internal/providers/qoder/bridge.go +++ b/internal/providers/qoder/bridge.go @@ -332,9 +332,19 @@ func (b *Bridge) applyJobToken(jt map[string]any, userType string) { SecurityOauthToken: securityOauth, RefreshToken: refreshToken, } - sess := qauth.NewSession(identity, b.machineID, b.machineToken, b.machineType) - b.mu.Lock() + // A refresh response that omits a token must not erase the one we hold: + // an empty securityOauthToken would make check-in/quota fail with "token + // unavailable", and an empty refreshToken would break the next renewal. + if securityOauth == "" { + securityOauth = b.securityOath + identity.SecurityOauthToken = securityOauth + } + if refreshToken == "" { + refreshToken = b.refreshToken + identity.RefreshToken = refreshToken + } + sess := qauth.NewSession(identity, b.machineID, b.machineToken, b.machineType) b.sess = sess b.identity = &identity b.refreshToken = refreshToken diff --git a/internal/providers/qoder/bridge_token_test.go b/internal/providers/qoder/bridge_token_test.go new file mode 100644 index 00000000..ab4c81bc --- /dev/null +++ b/internal/providers/qoder/bridge_token_test.go @@ -0,0 +1,41 @@ +package qoder + +import ( + "testing" + "time" +) + +// A renewal response that omits securityOauthToken/refreshToken must keep the +// previously held tokens instead of blanking them (#323). +func TestApplyJobTokenKeepsTokensWhenRefreshOmitsThem(t *testing.T) { + b := newTestBridge(t, "fake-pat-secret-0123456789abcdef") + b.applyJobToken(map[string]any{ + "id": "test-uid-12345", + "name": "tester", + "expireTime": float64(time.Now().Add(48 * time.Hour).UnixMilli()), + }, "personal_standard") + if got := b.CurrentSecurityOauth(); got != "test-security-oauth-token" { + t.Fatalf("securityOauth = %q, want previous token kept", got) + } + b.mu.Lock() + rt := b.refreshToken + ident := *b.identity + b.mu.Unlock() + if rt != "test-refresh-token" || ident.RefreshToken != "test-refresh-token" || ident.SecurityOauthToken != "test-security-oauth-token" { + t.Fatalf("tokens not preserved: rt=%q ident=%+v", rt, ident) + } +} + +// A renewal that does carry new tokens must replace the old ones. +func TestApplyJobTokenUsesNewTokens(t *testing.T) { + b := newTestBridge(t, "fake-pat-secret-0123456789abcdef") + b.applyJobToken(map[string]any{ + "id": "test-uid-12345", + "securityOauthToken": "new-sot", + "refreshToken": "new-rt", + "expireTime": float64(time.Now().Add(48 * time.Hour).UnixMilli()), + }, "personal_standard") + if got := b.CurrentSecurityOauth(); got != "new-sot" { + t.Fatalf("securityOauth = %q want new-sot", got) + } +} diff --git a/internal/providers/qoder/checkin.go b/internal/providers/qoder/checkin.go index 004579b6..01cf7dfd 100644 --- a/internal/providers/qoder/checkin.go +++ b/internal/providers/qoder/checkin.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -204,7 +205,9 @@ func (b *Bridge) Checkin(ctx context.Context) (providers.CheckinResult, error) { items := campaignsFrom(listRaw) benefits := creditCampaigns(items) if len(benefits) == 0 { - return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, nil + summary := campaignSummary(items) + log.Printf("[qoder-checkin] region=%s no credit campaigns: count=%d summary=%s", regionName(b.Region), len(items), summary) + return providers.CheckinResult{Status: "skipped", Message: skippedMessage("上游未返回可领取积分的签到活动", len(items), summary)}, nil } claimable := claimableCampaigns(benefits) if len(claimable) == 0 { @@ -213,7 +216,9 @@ func (b *Bridge) Checkin(ctx context.Context) (providers.CheckinResult, error) { return providers.CheckinResult{Status: "already", Message: "今日已签到"}, nil } } - return providers.CheckinResult{Status: "skipped", Message: "签到活动未开放"}, nil + summary := campaignSummary(items) + log.Printf("[qoder-checkin] region=%s credit campaigns not claimable: count=%d summary=%s", regionName(b.Region), len(items), summary) + return providers.CheckinResult{Status: "skipped", Message: skippedMessage("签到活动当前不可领取", len(items), summary)}, nil } // 2) Claim each claimable campaign. A claim that returns a non-CLAIMED @@ -510,3 +515,41 @@ func shortHash(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:])[:8] } + +// campaignSummary renders "actionType:claimStatus" pairs (no ids or tokens) so +// a skipped check-in carries the upstream state that caused it instead of a +// generic "not open" message. Capped to keep the persisted message short. +func campaignSummary(items []campaign) string { + if len(items) == 0 { + return "none" + } + const maxItems = 8 + parts := make([]string, 0, maxItems+1) + for i, c := range items { + if i == maxItems { + parts = append(parts, fmt.Sprintf("+%d", len(items)-maxItems)) + break + } + action := strings.TrimSpace(c.ActionType) + if action == "" { + action = "unknown" + } + status := strings.TrimSpace(c.ClaimStatus) + if status == "" { + status = "none" + } + parts = append(parts, action+":"+status) + } + return strings.Join(parts, ",") +} + +func skippedMessage(reason string, count int, summary string) string { + return fmt.Sprintf("%s(活动数 %d:%s)", reason, count, summary) +} + +func regionName(r *qauth.RegionConfig) string { + if r == nil { + return "" + } + return r.Name +} diff --git a/internal/providers/qoder/checkin_inprocess_test.go b/internal/providers/qoder/checkin_inprocess_test.go index 926263d1..ad12c634 100644 --- a/internal/providers/qoder/checkin_inprocess_test.go +++ b/internal/providers/qoder/checkin_inprocess_test.go @@ -174,7 +174,24 @@ func TestBridgeCheckinNoClaimable(t *testing.T) { name: "empty campaigns", resp: map[string]any{"campaigns": []map[string]any{}}, wantStatus: "skipped", - wantMsg: "签到活动未开放", + wantMsg: "上游未返回可领取积分的签到活动(活动数 0:none)", + }, + { + name: "only non-credit campaigns", + resp: map[string]any{"campaigns": []map[string]any{ + {"campaignId": "v1", "actionType": "VIEW", "claimStatus": "CLAIMABLE"}, + }}, + wantStatus: "skipped", + wantMsg: "上游未返回可领取积分的签到活动(活动数 1:VIEW:CLAIMABLE)", + }, + { + name: "credit campaign not claimable", + resp: map[string]any{"campaigns": []map[string]any{ + {"campaignId": "c1", "actionType": "CLAIM_BENEFIT", "claimStatus": "LOCKED"}, + {"campaignId": "c2", "actionType": "CLAIM_BENEFIT"}, + }}, + wantStatus: "skipped", + wantMsg: "签到活动当前不可领取(活动数 2:CLAIM_BENEFIT:LOCKED,CLAIM_BENEFIT:none)", }, { name: "all claimed", From 78d9a53888a6914ce334b807f15cf21343d20ba6 Mon Sep 17 00:00:00 2001 From: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:48:52 +0800 Subject: [PATCH 2/2] docs(changelog): use bilingual sections for qoder check-in fragment --- changelog/unreleased/qoder-checkin-diagnostics.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog/unreleased/qoder-checkin-diagnostics.md b/changelog/unreleased/qoder-checkin-diagnostics.md index d1905b4d..56500f1f 100644 --- a/changelog/unreleased/qoder-checkin-diagnostics.md +++ b/changelog/unreleased/qoder-checkin-diagnostics.md @@ -1 +1,7 @@ -- Qoder 签到:上游未返回可领取活动时,不再只显示"签到活动未开放",日志与账号卡片签到提示会带上活动数与 `actionType:claimStatus` 摘要;token 续期响应缺少 securityOauthToken / refreshToken 时保留原值,不再被清空。(#323) +### English + +- Qoder check-in: when upstream returns no claimable campaign, the log and the account card's check-in tooltip now show the campaign count and an `actionType:claimStatus` summary instead of only "not open"; a token renewal response that omits securityOauthToken / refreshToken no longer clears the held tokens. (#323) + +### 中文 + +- Qoder 签到:上游未返回可领取活动时,日志与账号卡片签到提示会带上活动数与 `actionType:claimStatus` 摘要,而不再只显示"签到活动未开放";token 续期响应缺少 securityOauthToken / refreshToken 时保留原值,不再被清空。(#323)