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
7 changes: 7 additions & 0 deletions changelog/unreleased/qoder-checkin-diagnostics.md
Original file line number Diff line number Diff line change
@@ -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)
14 changes: 12 additions & 2 deletions internal/providers/qoder/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/providers/qoder/bridge_token_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
47 changes: 45 additions & 2 deletions internal/providers/qoder/checkin.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
19 changes: 18 additions & 1 deletion internal/providers/qoder/checkin_inprocess_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading