diff --git a/CHANGELOG.md b/CHANGELOG.md index 350d94d..017d208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`. - Show a compact version history on the System page: one release open at a time, with notes and restore for the three previous versions - Use plain update copy on the System page: download the update, tap Update now, then refresh after a short countdown - Explain Models-page defaults: Trae Max is a larger context window, and reasoning intensity is a fallback when the request omits it +- Skip WorkBuddy check-in for the rest of the local day after a success or already-checked-in result, and treat HTTP 400 “already checked in” as that result instead of a failure ### 中文 - 系统页用折叠列表展示近期版本:一次只展开一条,可看说明,并可恢复到最近三个旧版本 - 系统页更新文案改为普通说法:先下载更新,再点立即更新,完成后倒计时刷新页面 - 模型页补充说明:Trae 的 Max 是更大上下文,推理强度只是请求未指定时的默认档,不会锁死每次调用 +- 当天本地日历日已签到成功或确认「已签到」后,不再重复打上游、不再写入签到记录;HTTP 400 的「今天已签到」按已签到处理,不再记成失败 ## 0.3.1 - 2026-09-07 diff --git a/internal/accounts/manager.go b/internal/accounts/manager.go index 68b8624..5e6fbd8 100644 --- a/internal/accounts/manager.go +++ b/internal/accounts/manager.go @@ -1455,6 +1455,12 @@ func (m *Manager) CheckinAccount(ctx context.Context, accountID string) (Account if account.Provider != "workbuddy" { return account, fmt.Errorf("check-in is only available for WorkBuddy accounts") } + if checkedInLocalDay(account.LastCheckinAt, account.LastCheckinStatus, time.Now()) { + if adapter, ok := m.providers.Get("workbuddy"); ok && adapter.Prober != nil { + m.fetchProviderQuota(ctx, accountID, adapter.Prober) + } + return m.store.Get(ctx, accountID) + } msg, checkErr := m.workbuddy.DailyCheckin(ctx, accountID) if msg == "" && checkErr != nil { msg = checkErr.Error() @@ -1499,12 +1505,41 @@ func (m *Manager) CheckinOptedIn(ctx context.Context) { if account.Provider != "workbuddy" || !account.Enabled || !account.WorkBuddyAutoCheckin { continue } + if checkedInLocalDay(account.LastCheckinAt, account.LastCheckinStatus, time.Now()) { + continue + } if _, err := m.CheckinAccount(ctx, account.ID); err != nil { log.Printf("workbuddy checkin account_id=%s op=checkin err=%v", account.ID, err) } } } +// checkedInLocalDay is true when the last recorded check-in is success or +// already on the process-local calendar day. Error rows do not skip, so the +// evening slot can retry a morning miss. +func checkedInLocalDay(at, status string, now time.Time) bool { + switch strings.TrimSpace(status) { + case "success", "already": + default: + return false + } + raw := strings.TrimSpace(at) + if raw == "" { + return false + } + parsed, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + parsed, err = time.Parse(time.RFC3339, raw) + if err != nil { + return false + } + } + loc := now.Location() + localAt := parsed.In(loc) + localNow := now.In(loc) + return localAt.Year() == localNow.Year() && localAt.YearDay() == localNow.YearDay() +} + // KeepaliveWorkBuddy refreshes tokens for enabled WorkBuddy accounts. // When onlyOptIn is true, only auto-checkin accounts are touched (scheduled // path). Manual/batch keepalive can pass false. diff --git a/internal/accounts/manager_test.go b/internal/accounts/manager_test.go index b9d80d5..56c957b 100644 --- a/internal/accounts/manager_test.go +++ b/internal/accounts/manager_test.go @@ -1384,3 +1384,137 @@ func TestEnsureModelCatalogsDoesNotBlock(t *testing.T) { t.Fatal("EnsureModelCatalogs blocked >2s; must be async/non-blocking") } } + +type fakeCheckinMaintainer struct { + calls int + msg string + err error +} + +func (f *fakeCheckinMaintainer) DailyCheckin(context.Context, string) (string, error) { + f.calls++ + return f.msg, f.err +} + +func (f *fakeCheckinMaintainer) Keepalive(context.Context, string) error { return nil } + +type fakeAlreadyCheckedInError struct{ msg string } + +func (e fakeAlreadyCheckedInError) Error() string { return e.msg } +func (fakeAlreadyCheckedInError) AlreadyCheckedIn() bool { return true } + +func TestCheckedInLocalDay(t *testing.T) { + loc := time.FixedZone("CST", 8*3600) + now := time.Date(2026, 9, 7, 21, 0, 0, 0, loc) + today := time.Date(2026, 9, 7, 0, 1, 28, 0, loc).UTC().Format(time.RFC3339Nano) + yesterday := time.Date(2026, 9, 6, 17, 0, 2, 0, loc).UTC().Format(time.RFC3339Nano) + if !checkedInLocalDay(today, "success", now) { + t.Fatal("same-day success must skip") + } + if !checkedInLocalDay(today, "already", now) { + t.Fatal("same-day already must skip") + } + if checkedInLocalDay(today, "error", now) { + t.Fatal("same-day error must retry") + } + if checkedInLocalDay(yesterday, "success", now) { + t.Fatal("yesterday success must not skip") + } + if checkedInLocalDay("", "success", now) { + t.Fatal("empty timestamp must not skip") + } +} + +func TestCheckinOptedInSkipsSameDaySuccess(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "qoder.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Create(ctx, CreateAccount{ + Name: "wb", Provider: "workbuddy", Region: "cn", Enabled: true, + WorkBuddyAutoCheckin: boolPtr(true), + }) + if err != nil { + t.Fatal(err) + } + if err := store.RecordCheckin(ctx, account.ID, "success", "ok", time.Now().UTC()); err != nil { + t.Fatal(err) + } + ops := &fakeCheckinMaintainer{msg: "ok"} + manager := NewManager(ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) + defer manager.Close() + manager.SetWorkBuddy(ops) + manager.CheckinOptedIn(ctx) + if ops.calls != 0 { + t.Fatalf("scheduled check-in must skip same-day success, calls=%d", ops.calls) + } + records, err := store.ListCheckinRecords(ctx, account.ID, 20) + if err != nil || len(records) != 1 { + t.Fatalf("records=%+v err=%v", records, err) + } +} + +func TestCheckinOptedInRetriesSameDayError(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "qoder.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Create(ctx, CreateAccount{ + Name: "wb", Provider: "workbuddy", Region: "cn", Enabled: true, + WorkBuddyAutoCheckin: boolPtr(true), + }) + if err != nil { + t.Fatal(err) + } + if err := store.RecordCheckin(ctx, account.ID, "error", "timeout", time.Now().UTC()); err != nil { + t.Fatal(err) + } + ops := &fakeCheckinMaintainer{msg: "ok"} + manager := NewManager(ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) + defer manager.Close() + manager.SetWorkBuddy(ops) + manager.CheckinOptedIn(ctx) + if ops.calls != 1 { + t.Fatalf("same-day error must retry, calls=%d", ops.calls) + } +} + +func TestCheckinAccountRecordsFirstAlreadyThenSkips(t *testing.T) { + ctx := context.Background() + store, err := OpenStore(filepath.Join(t.TempDir(), "qoder.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + account, err := store.Create(ctx, CreateAccount{ + Name: "wb", Provider: "workbuddy", Region: "cn", Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + ops := &fakeCheckinMaintainer{msg: "今天已签到,请明天再来", err: fakeAlreadyCheckedInError{msg: "今天已签到,请明天再来"}} + manager := NewManager(ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{}) + defer manager.Close() + manager.SetWorkBuddy(ops) + updated, err := manager.CheckinAccount(ctx, account.ID) + if err != nil { + t.Fatal(err) + } + if updated.LastCheckinStatus != "already" || ops.calls != 1 { + t.Fatalf("first already: status=%q calls=%d", updated.LastCheckinStatus, ops.calls) + } + if _, err := manager.CheckinAccount(ctx, account.ID); err != nil { + t.Fatal(err) + } + if ops.calls != 1 { + t.Fatalf("second already must skip upstream, calls=%d", ops.calls) + } + records, err := store.ListCheckinRecords(ctx, account.ID, 20) + if err != nil || len(records) != 1 || records[0].Status != "already" { + t.Fatalf("records=%+v err=%v", records, err) + } +} diff --git a/internal/providers/workbuddy/client.go b/internal/providers/workbuddy/client.go index 62cf1f1..98dbce6 100644 --- a/internal/providers/workbuddy/client.go +++ b/internal/providers/workbuddy/client.go @@ -717,6 +717,9 @@ func (c *Client) DailyCheckin(ctx context.Context, accountID string) (string, er if classified.Kind == accounts.KindAuth { return "", fmt.Errorf("workbuddy checkin session dead: re-login required") } + if msg, ok := alreadyCheckedInMessage(status, body); ok { + return msg, AlreadyCheckedInError{Msg: msg} + } if status >= 300 { return "", fmt.Errorf("checkin status=%d: %s", status, text) } @@ -726,10 +729,6 @@ func (c *Client) DailyCheckin(ctx context.Context, accountID string) (string, er } msg := strings.TrimSpace(env.Msg) if env.Code != 0 { - lower := strings.ToLower(msg) - if strings.Contains(msg, "已签到") || (strings.Contains(lower, "already") && strings.Contains(lower, "check")) { - return msg, AlreadyCheckedInError{Msg: msg} - } if msg == "" { msg = fmt.Sprintf("checkin code=%d", env.Code) } @@ -741,6 +740,25 @@ func (c *Client) DailyCheckin(ctx context.Context, accountID string) (string, er return msg, nil } +func alreadyCheckedInMessage(status int, body []byte) (string, bool) { + var env envelope + msg := "" + if json.Unmarshal(body, &env) == nil { + msg = strings.TrimSpace(env.Msg) + if env.Code == 0 && status < 300 { + return "", false + } + } + if msg == "" { + msg = strings.TrimSpace(string(body)) + } + lower := strings.ToLower(msg) + if strings.Contains(msg, "已签到") || (strings.Contains(lower, "already") && strings.Contains(lower, "check")) { + return msg, true + } + return "", false +} + func retryDailyCheckin(ctx context.Context, err error, status, attempt int) bool { if attempt >= len(dailyCheckinRetryDelays) || ctx.Err() != nil { return false diff --git a/internal/providers/workbuddy/client_test.go b/internal/providers/workbuddy/client_test.go index f884f85..9a045a9 100644 --- a/internal/providers/workbuddy/client_test.go +++ b/internal/providers/workbuddy/client_test.go @@ -732,6 +732,26 @@ func TestDailyCheckinAlreadyCheckedIn(t *testing.T) { } } +func TestDailyCheckinAlreadyCheckedInHTTP400(t *testing.T) { + client, store := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"code": 10001, "msg": "今天已签到,请明天再来", "requestId": "2647945e-7f7e-44cc-9e34-737dd078119e"}) + })) + payload, _ := json.Marshal(Credential{ + AccessToken: "at", RefreshToken: "rt", ExpiresAt: 4102444800, Domain: DomainCN, UID: "u1", + }) + _ = store.SaveCredentialPayload(context.Background(), "acc1", CredentialFormat, payload) + msg, err := client.DailyCheckin(context.Background(), "acc1") + var already AlreadyCheckedInError + if !errors.As(err, &already) { + t.Fatalf("HTTP 400 already-checked-in must not be a generic failure, err=%v", err) + } + if msg != "今天已签到,请明天再来" { + t.Fatalf("msg=%q", msg) + } +} + func TestDailyCheckinRetriesTransientFailures(t *testing.T) { var calls atomic.Int32 client, store := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {