diff --git a/changelog/unreleased/qoder-checkin-diagnostics.md b/changelog/unreleased/qoder-checkin-diagnostics.md new file mode 100644 index 00000000..56500f1f --- /dev/null +++ b/changelog/unreleased/qoder-checkin-diagnostics.md @@ -0,0 +1,7 @@ +### 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) 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",