Skip to content

Commit b41b203

Browse files
committed
feat: add account enable scheduling toggle
1 parent 8097558 commit b41b203

14 files changed

Lines changed: 5961 additions & 5462 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Database Guidelines
2+
3+
> Database patterns and conventions for this project.
4+
5+
---
6+
7+
## Overview
8+
9+
<!--
10+
Document your project's database conventions here.
11+
12+
Questions to answer:
13+
- What ORM/query library do you use?
14+
- How are migrations managed?
15+
- What are the naming conventions for tables/columns?
16+
- How do you handle transactions?
17+
-->
18+
19+
(To be filled by the team)
20+
21+
---
22+
23+
## Query Patterns
24+
25+
<!-- How should queries be written? Batch operations? -->
26+
27+
(To be filled by the team)
28+
29+
---
30+
31+
## Migrations
32+
33+
<!-- How to create and run migrations -->
34+
35+
### Scenario: Backward-Compatible Account Flags
36+
37+
#### 1. Scope / Trigger
38+
39+
- Trigger: adding a persisted account-level boolean flag used by runtime/admin behavior.
40+
- This project supports both PostgreSQL and SQLite, so every new account column needs both migration paths.
41+
42+
#### 2. Signatures
43+
44+
- PostgreSQL migration: `ALTER TABLE accounts ADD COLUMN IF NOT EXISTS <flag> BOOLEAN DEFAULT <value>;`
45+
- SQLite migration registry: add `{"accounts", "<flag>", "INTEGER DEFAULT <0|1>"}` to `migrateSQLite`.
46+
- Row model: add the field to `AccountRow` and to all account row `SELECT`/`Scan` lists that return full account state.
47+
48+
#### 3. Contracts
49+
50+
- Account flags that should preserve old data must use a default matching existing behavior.
51+
- Reads should use `COALESCE(<flag>, <default>)` so older rows and partially migrated databases retain expected semantics.
52+
- Account list queries should continue loading all non-deleted accounts unless the feature explicitly changes account visibility.
53+
54+
#### 4. Validation & Error Matrix
55+
56+
- Missing column before migration -> migration must create it without data loss.
57+
- Existing row with `NULL` flag -> `COALESCE` must return the default behavior.
58+
- Setter update failure -> return the database error to the admin handler; do not silently ignore it.
59+
60+
#### 5. Good/Base/Bad Cases
61+
62+
- Good: `enabled` defaults to true and `COALESCE(enabled, true)` is selected into `AccountRow.Enabled`.
63+
- Base: inserting an account without specifying the flag keeps old behavior.
64+
- Bad: filtering rows by the flag in `ListActive` when the flag only controls scheduling.
65+
66+
#### 6. Tests Required
67+
68+
- Fresh SQLite database initializes with the new column.
69+
- Inserted account has the expected default flag value.
70+
- Setter toggles the flag and `ListActive` reflects the value.
71+
72+
#### 7. Wrong vs Correct
73+
74+
Wrong:
75+
```sql
76+
SELECT ... FROM accounts WHERE status <> 'deleted' AND enabled = true;
77+
```
78+
79+
Correct:
80+
```sql
81+
SELECT ..., COALESCE(enabled, true)
82+
FROM accounts
83+
WHERE status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted';
84+
```
85+
86+
---
87+
88+
## Naming Conventions
89+
90+
<!-- Table names, column names, index names -->
91+
92+
(To be filled by the team)
93+
94+
---
95+
96+
## Common Mistakes
97+
98+
<!-- Database-related mistakes your team has made -->
99+
100+
(To be filled by the team)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Quality Guidelines
2+
3+
> Code quality standards for backend development.
4+
5+
---
6+
7+
## Overview
8+
9+
<!--
10+
Document your project's quality standards here.
11+
12+
Questions to answer:
13+
- What patterns are forbidden?
14+
- What linting rules do you enforce?
15+
- What are your testing requirements?
16+
- What code review standards apply?
17+
-->
18+
19+
(To be filled by the team)
20+
21+
---
22+
23+
## Forbidden Patterns
24+
25+
<!-- Patterns that should never be used and why -->
26+
27+
(To be filled by the team)
28+
29+
---
30+
31+
## Required Patterns
32+
33+
<!-- Patterns that must always be used -->
34+
35+
### Scenario: Dispatch-Only Account Gates
36+
37+
#### 1. Scope / Trigger
38+
39+
- Trigger: adding or changing account flags that control whether an account may receive new proxied requests.
40+
- This is a cross-layer contract because the flag is stored in DB, exposed by admin API, synced into runtime state, and shown in the UI.
41+
42+
#### 2. Signatures
43+
44+
- Runtime flag: `auth.Account.DispatchPaused int32`.
45+
- Admin API: `POST /api/admin/accounts/:id/enable` with `{"enabled": boolean}`.
46+
- Database setter: `SetAccountEnabled(ctx context.Context, id int64, enabled bool) error`.
47+
48+
#### 3. Contracts
49+
50+
- `enabled=true`: account is eligible for normal scheduler/dispatch selection if all existing health, cooldown, usage, API-key, and concurrency gates also pass.
51+
- `enabled=false`: account is excluded from scheduler/dispatch selection only.
52+
- `enabled=false` must not block RT refresh, AT refresh, manual refresh, token cache writes, testing, usage probes, recovery probes, import/export, auto-clean, account list visibility, credential updates, or existing `locked` semantics.
53+
- `locked` is separate and means auto-clean protection; do not reuse it as a dispatch gate.
54+
55+
#### 4. Validation & Error Matrix
56+
57+
- Invalid `:id` -> `400` with the existing admin error response helper.
58+
- Malformed JSON body -> `400`.
59+
- Database update failure -> `500`.
60+
- Nonexistent IDs follow the existing account flag setter behavior unless the setter is explicitly changed across all account flag endpoints.
61+
62+
#### 5. Good/Base/Bad Cases
63+
64+
- Good: `IsAvailable()` and `fastSchedulerSnapshot()` both reject `DispatchPaused`.
65+
- Base: disabled accounts still appear in `ListAccounts` and can be manually refreshed.
66+
- Bad: filtering `enabled=false` inside `ListActive`, `parallelRefreshAll`, usage probes, recovery probes, or clean-up paths.
67+
68+
#### 6. Tests Required
69+
70+
- Scheduler tests proving disabled accounts are skipped by both normal and fast scheduler paths.
71+
- Regression tests proving disabled accounts still participate in usage probes, recovery probes, and auto-clean.
72+
- Database tests proving new accounts default to enabled and can be toggled.
73+
74+
#### 7. Wrong vs Correct
75+
76+
Wrong:
77+
```go
78+
// This hides disabled accounts from refresh, probes, UI, and clean-up.
79+
SELECT ... FROM accounts WHERE enabled = true
80+
```
81+
82+
Correct:
83+
```go
84+
// Load all non-deleted accounts and apply the enabled gate only in dispatch selection.
85+
SELECT ..., COALESCE(enabled, true) FROM accounts WHERE status <> 'deleted'
86+
```
87+
88+
---
89+
90+
## Testing Requirements
91+
92+
<!-- What level of testing is expected -->
93+
94+
(To be filled by the team)
95+
96+
---
97+
98+
## Code Review Checklist
99+
100+
<!-- What reviewers should check -->
101+
102+
(To be filled by the team)

admin/handler.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
109109
api.PATCH("/accounts/:id/scheduler", h.UpdateAccountScheduler)
110110
api.DELETE("/accounts/:id", h.DeleteAccount)
111111
api.POST("/accounts/:id/refresh", h.RefreshAccount)
112+
api.POST("/accounts/:id/enable", h.ToggleAccountEnabled)
112113
api.POST("/accounts/:id/lock", h.ToggleAccountLock)
113114
api.POST("/accounts/:id/reset-status", h.ResetAccountStatus)
114115
api.GET("/accounts/:id/test", h.TestConnection)
@@ -294,6 +295,7 @@ type accountResponse struct {
294295
LastRateLimitedAt string `json:"last_rate_limited_at,omitempty"`
295296
LastTimeoutAt string `json:"last_timeout_at,omitempty"`
296297
LastServerErrorAt string `json:"last_server_error_at,omitempty"`
298+
Enabled bool `json:"enabled"`
297299
Locked bool `json:"locked"`
298300
AllowedAPIKeyIDs []int64 `json:"allowed_api_key_ids"`
299301
}
@@ -349,6 +351,7 @@ func (h *Handler) ListAccounts(c *gin.Context) {
349351
Status: row.Status,
350352
ATOnly: row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
351353
ProxyURL: row.ProxyURL,
354+
Enabled: row.Enabled,
352355
Locked: row.Locked,
353356
AllowedAPIKeyIDs: row.GetCredentialInt64Slice("allowed_api_key_ids"),
354357
ScoreBiasOverride: nullableInt64Pointer(row.ScoreBiasOverride),
@@ -1651,6 +1654,40 @@ func (h *Handler) RefreshAccount(c *gin.Context) {
16511654
writeMessage(c, http.StatusOK, "账号刷新成功")
16521655
}
16531656

1657+
// ToggleAccountEnabled 切换账号是否参与调度选择
1658+
func (h *Handler) ToggleAccountEnabled(c *gin.Context) {
1659+
idStr := c.Param("id")
1660+
id, err := strconv.ParseInt(idStr, 10, 64)
1661+
if err != nil {
1662+
writeError(c, http.StatusBadRequest, "无效的账号 ID")
1663+
return
1664+
}
1665+
1666+
var req struct {
1667+
Enabled bool `json:"enabled"`
1668+
}
1669+
if err := c.ShouldBindJSON(&req); err != nil {
1670+
writeError(c, http.StatusBadRequest, "请求格式错误")
1671+
return
1672+
}
1673+
1674+
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
1675+
defer cancel()
1676+
1677+
if err := h.db.SetAccountEnabled(ctx, id, req.Enabled); err != nil {
1678+
writeError(c, http.StatusInternalServerError, "更新启用状态失败: "+err.Error())
1679+
return
1680+
}
1681+
1682+
h.store.ApplyAccountEnabled(id, req.Enabled)
1683+
1684+
if req.Enabled {
1685+
writeMessage(c, http.StatusOK, "账号已启用")
1686+
} else {
1687+
writeMessage(c, http.StatusOK, "账号已禁用")
1688+
}
1689+
}
1690+
16541691
// ToggleAccountLock 切换账号的锁定状态
16551692
func (h *Handler) ToggleAccountLock(c *gin.Context) {
16561693
idStr := c.Param("id")

auth/fast_scheduler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,9 @@ func (a *Account) fastSchedulerSnapshot(baseLimit int64, now time.Time) (Account
402402
}
403403

404404
available := a.Status != StatusError && tier != HealthTierBanned && a.AccessToken != ""
405+
if atomic.LoadInt32(&a.DispatchPaused) != 0 {
406+
available = false
407+
}
405408
if a.Status == StatusCooldown && now.Before(a.CooldownUtil) && !a.premium5hCooldownSuppressedLocked(now) {
406409
available = false
407410
}

0 commit comments

Comments
 (0)