From eb7f9fd66c9bd89f7225841e63184dcda803e38f Mon Sep 17 00:00:00 2001
From: zhoujun0601
Date: Thu, 13 Aug 2026 09:03:47 -0400
Subject: [PATCH] feat: validate Team workspace connections
---
README.md | 2 +
backend/internal/app/api.go | 49 ++++++++++-
backend/internal/app/app.go | 38 ++++++++-
backend/internal/app/runtime_test.go | 23 ++++++
backend/internal/store/store.go | 107 +++++++++++++++++++++----
backend/internal/store/store_test.go | 50 ++++++++++++
backend/internal/web/dist/index.html | 4 +-
frontend/src/main.tsx | 86 +++++++++++++++++++-
frontend/src/styles.css | 2 +-
frontend/tests/settings-layout.spec.ts | 12 ++-
10 files changed, 345 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
index b8e2299..692d198 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,8 @@ http://服务器地址:8080
如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。
+创建连接时可选择预期的“个人订阅”或“Team / Business 工作区”。授权完成后,Codex Helper 会使用 app-server 返回的真实套餐进行校验;`team` 和当前 Business 系列套餐均识别为团队工作区。设备码接口本身不能指定工作区 ID,因此同一邮箱包含多个空间时,需要在授权页面进入目标空间;如果页面提示类型不匹配,请退出该连接后重新授权。
+
## 通用设置与提醒时间
进入“设置中心” → “通用”,可设置:
diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go
index 89ee13e..a59aeda 100644
--- a/backend/internal/app/api.go
+++ b/backend/internal/app/api.go
@@ -13,6 +13,7 @@ import (
"time"
"codex-helper/internal/security"
+ "codex-helper/internal/store"
)
func (a *App) api(w http.ResponseWriter, r *http.Request) {
@@ -57,7 +58,8 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
}
case p == "accounts" && r.Method == "POST":
var in struct {
- DisplayName string `json:"displayName"`
+ DisplayName string `json:"displayName"`
+ ExpectedKind string `json:"expectedKind"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
@@ -67,7 +69,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
if in.DisplayName == "" {
in.DisplayName = "新账号"
}
- x, e := a.store.CreateAccount(in.DisplayName)
+ if in.ExpectedKind == "" {
+ in.ExpectedKind = "any"
+ }
+ if !store.ValidExpectedKind(in.ExpectedKind) {
+ jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
+ break
+ }
+ x, e := a.store.CreateAccount(in.DisplayName, in.ExpectedKind)
if e == nil {
a.addRuntime(x.ID)
jsonOut(w, 201, x)
@@ -164,13 +173,27 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
switch {
case action == "" && r.Method == "PUT":
var in struct {
- DisplayName string `json:"displayName"`
+ DisplayName string `json:"displayName"`
+ ExpectedKind string `json:"expectedKind"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
return
}
- e = a.store.RenameAccount(id, strings.TrimSpace(in.DisplayName))
+ if in.ExpectedKind == "" {
+ accounts, _ := a.store.Accounts()
+ for _, account := range accounts {
+ if account.ID == id {
+ in.ExpectedKind = account.ExpectedKind
+ break
+ }
+ }
+ }
+ if !store.ValidExpectedKind(in.ExpectedKind) {
+ jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
+ return
+ }
+ e = a.store.UpdateAccountSettings(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind)
if e == nil {
jsonOut(w, 200, map[string]bool{"ok": true})
}
@@ -393,6 +416,24 @@ func (a *App) syncAccount(ctx context.Context, id int64) error {
d.Limits = flattenLimit(*lr.RateLimits)
}
}
+ // Some app-server versions expose the workspace plan only on limit buckets.
+ if ar.Account != nil && store.AccountKind(d.Account.PlanType) == "unknown" {
+ var fallback *string
+ consistent := true
+ for _, x := range d.Limits {
+ if store.AccountKind(x.PlanType) == "unknown" {
+ continue
+ }
+ if fallback == nil {
+ fallback = x.PlanType
+ } else if store.AccountKind(fallback) != store.AccountKind(x.PlanType) {
+ consistent = false
+ }
+ }
+ if consistent && fallback != nil {
+ d.Account.PlanType = fallback
+ }
+ }
var ur struct {
Summary UsageSummary `json:"summary"`
Daily []struct {
diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go
index 419afae..b32078f 100644
--- a/backend/internal/app/app.go
+++ b/backend/internal/app/app.go
@@ -135,11 +135,45 @@ func (a *App) keepCodex() {
}
func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) {
return func(method string, _ json.RawMessage) {
- if method == "account/updated" || method == "account/rateLimits/updated" {
- go a.syncAccount(context.Background(), id)
+ if method == "account/login/completed" || method == "account/updated" || method == "account/rateLimits/updated" {
+ go a.syncAccountWithRetry(id, method == "account/login/completed")
}
}
}
+
+func (a *App) syncAccountWithRetry(id int64, requireClassifiedAccount bool) {
+ delays := []time.Duration{0, time.Second, 3 * time.Second}
+ var err error
+ for _, delay := range delays {
+ if delay > 0 {
+ select {
+ case <-a.ctx.Done():
+ return
+ case <-time.After(delay):
+ }
+ }
+ err = a.syncAccount(context.Background(), id)
+ if err == nil {
+ rt := a.runtime(id)
+ if !requireClassifiedAccount || rt == nil || accountClassificationReady(rt) {
+ return
+ }
+ err = errors.New("登录已完成,但工作区套餐尚未就绪")
+ }
+ }
+ if rt := a.runtime(id); rt != nil {
+ rt.syncing.Lock()
+ rt.dash.Stale = true
+ rt.dash.LastError = err.Error()
+ rt.syncing.Unlock()
+ }
+}
+
+func accountClassificationReady(rt *accountRuntime) bool {
+ rt.syncing.Lock()
+ defer rt.syncing.Unlock()
+ return rt.dash.Account.Connected && store.AccountKind(rt.dash.Account.PlanType) != "unknown"
+}
func (a *App) addRuntime(id int64) {
dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex")
if id == 1 {
diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go
index 0a96c47..88a2628 100644
--- a/backend/internal/app/runtime_test.go
+++ b/backend/internal/app/runtime_test.go
@@ -158,3 +158,26 @@ func TestDeviceLoginStartsColdRuntime(t *testing.T) {
t.Fatalf("starts = %d, initializes = %d, calls = %d; want 1 each", starts, initializes, calls)
}
}
+
+func TestAccountClassificationReadyRequiresConnectedKnownPlan(t *testing.T) {
+ team := "team"
+ unknown := "unknown"
+ tests := []struct {
+ name string
+ account AccountView
+ want bool
+ }{
+ {name: "disconnected", account: AccountView{PlanType: &team}, want: false},
+ {name: "missing plan", account: AccountView{Connected: true}, want: false},
+ {name: "unknown plan", account: AccountView{Connected: true, PlanType: &unknown}, want: false},
+ {name: "classified", account: AccountView{Connected: true, PlanType: &team}, want: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rt := &accountRuntime{dash: Dashboard{Account: tt.account}}
+ if got := accountClassificationReady(rt); got != tt.want {
+ t.Fatalf("accountClassificationReady() = %v; want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 9c588a5..6b5f482 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
_ "modernc.org/sqlite"
@@ -59,12 +60,31 @@ func (s *Store) migrateAccounts() error {
display_name TEXT NOT NULL,
email TEXT,
plan_type TEXT,
+ expected_kind TEXT NOT NULL DEFAULT 'any',
connected INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`); err != nil {
return err
}
+ var hasExpectedKind bool
+ rows, qerr := tx.Query("PRAGMA table_info(accounts)")
+ if qerr != nil {
+ return qerr
+ }
+ for rows.Next() {
+ var cid, notnull, pk int
+ var name, typ string
+ var def any
+ _ = rows.Scan(&cid, &name, &typ, ¬null, &def, &pk)
+ hasExpectedKind = hasExpectedKind || name == "expected_kind"
+ }
+ rows.Close()
+ if !hasExpectedKind {
+ if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN expected_kind TEXT NOT NULL DEFAULT 'any'"); err != nil {
+ return err
+ }
+ }
var count int
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
return err
@@ -122,17 +142,21 @@ func (s *Store) migrateAccounts() error {
}
type Account struct {
- ID int64 `json:"id"`
- DisplayName string `json:"displayName"`
- Email *string `json:"email"`
- PlanType *string `json:"planType"`
- Connected bool `json:"connected"`
- CreatedAt int64 `json:"createdAt"`
- UpdatedAt int64 `json:"updatedAt"`
+ ID int64 `json:"id"`
+ DisplayName string `json:"displayName"`
+ Email *string `json:"email"`
+ PlanType *string `json:"planType"`
+ ExpectedKind string `json:"expectedKind"`
+ ActualKind string `json:"actualKind"`
+ ValidationStatus string `json:"validationStatus"`
+ PossibleDuplicate bool `json:"possibleDuplicate"`
+ Connected bool `json:"connected"`
+ CreatedAt int64 `json:"createdAt"`
+ UpdatedAt int64 `json:"updatedAt"`
}
func (s *Store) Accounts() ([]Account, error) {
- rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,connected,created_at,updated_at FROM accounts ORDER BY id")
+ rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,connected,created_at,updated_at FROM accounts ORDER BY id")
if e != nil {
return nil, e
}
@@ -140,24 +164,40 @@ func (s *Store) Accounts() ([]Account, error) {
out := []Account{}
for rows.Next() {
var a Account
- if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
+ if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
return nil, e
}
+ a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
out = append(out, a)
}
+ for i := range out {
+ if out[i].Email == nil || out[i].ActualKind == "unknown" {
+ continue
+ }
+ for j := range out {
+ if i != j && out[j].Email != nil && strings.EqualFold(*out[i].Email, *out[j].Email) && out[i].ActualKind == out[j].ActualKind {
+ out[i].PossibleDuplicate = true
+ break
+ }
+ }
+ }
return out, rows.Err()
}
-func (s *Store) CreateAccount(name string) (Account, error) {
+func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) {
+ expectedKind := "any"
+ if len(kinds) > 0 {
+ expectedKind = kinds[0]
+ }
now := time.Now().Unix()
- r, e := s.DB.Exec("INSERT INTO accounts(display_name,created_at,updated_at) VALUES(?,?,?)", name, now, now)
+ r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,created_at,updated_at) VALUES(?,?,?,?)", name, expectedKind, now, now)
if e != nil {
return Account{}, e
}
id, _ := r.LastInsertId()
- return Account{ID: id, DisplayName: name, CreatedAt: now, UpdatedAt: now}, nil
+ return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil
}
-func (s *Store) RenameAccount(id int64, name string) error {
- r, e := s.DB.Exec("UPDATE accounts SET display_name=?,updated_at=? WHERE id=?", name, time.Now().Unix(), id)
+func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error {
+ r, e := s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id)
if e != nil {
return e
}
@@ -167,6 +207,45 @@ func (s *Store) RenameAccount(id int64, name string) error {
}
return nil
}
+func (s *Store) RenameAccount(id int64, name string) error {
+ var kind string
+ if err := s.DB.QueryRow("SELECT expected_kind FROM accounts WHERE id=?", id).Scan(&kind); err != nil {
+ return err
+ }
+ return s.UpdateAccountSettings(id, name, kind)
+}
+
+func ValidExpectedKind(kind string) bool {
+ return kind == "any" || kind == "personal" || kind == "team"
+}
+
+func AccountKind(plan *string) string {
+ if plan == nil {
+ return "unknown"
+ }
+ switch strings.ToLower(strings.TrimSpace(*plan)) {
+ case "free", "go", "plus", "pro", "prolite":
+ return "personal"
+ case "team", "business", "self_serve_business_prolite", "self_serve_business_usage_based":
+ return "team"
+ default:
+ return "unknown"
+ }
+}
+
+func validationStatus(expected string, connected bool, plan *string) string {
+ if !connected {
+ return "pending"
+ }
+ actual := AccountKind(plan)
+ if actual == "unknown" {
+ return "unknown"
+ }
+ if expected == "any" || expected == actual {
+ return "matched"
+ }
+ return "mismatch"
+}
func (s *Store) UpdateAccount(id int64, email, plan *string, connected bool) error {
_, e := s.DB.Exec("UPDATE accounts SET email=?,plan_type=?,connected=?,updated_at=? WHERE id=?", email, plan, connected, time.Now().Unix(), id)
return e
diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go
index 97be351..d83b262 100644
--- a/backend/internal/store/store_test.go
+++ b/backend/internal/store/store_test.go
@@ -54,6 +54,56 @@ func TestAccountsAndPerAccountUsage(t *testing.T) {
}
}
+func TestAccountKindAndValidation(t *testing.T) {
+ tests := []struct{ plan, kind string }{
+ {"plus", "personal"}, {"Pro", "personal"}, {"team", "team"},
+ {"business", "team"}, {"self_serve_business_usage_based", "team"},
+ {"enterprise", "unknown"}, {"", "unknown"},
+ }
+ for _, tt := range tests {
+ plan := tt.plan
+ if got := AccountKind(&plan); got != tt.kind {
+ t.Errorf("AccountKind(%q) = %q; want %q", tt.plan, got, tt.kind)
+ }
+ }
+ if got := validationStatus("team", true, ptr("plus")); got != "mismatch" {
+ t.Fatalf("team/plus validation = %q; want mismatch", got)
+ }
+ if got := validationStatus("team", true, ptr("business")); got != "matched" {
+ t.Fatalf("team/business validation = %q; want matched", got)
+ }
+}
+
+func TestExistingAccountsGainExpectedKind(t *testing.T) {
+ dir := t.TempDir()
+ db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = db.Exec(`CREATE TABLE accounts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, display_name TEXT NOT NULL, email TEXT,
+ plan_type TEXT, connected INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
+ ); INSERT INTO accounts VALUES(1,'旧连接','user@example.com','team',1,1,1);`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+ s, err := Open(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.DB.Close()
+ accounts, err := s.Accounts()
+ if err != nil || len(accounts) != 1 {
+ t.Fatalf("accounts = %#v, %v", accounts, err)
+ }
+ if accounts[0].ExpectedKind != "any" || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" {
+ t.Fatalf("migrated account = %#v", accounts[0])
+ }
+}
+
+func ptr(value string) *string { return &value }
+
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {
dir := t.TempDir()
db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db"))
diff --git a/backend/internal/web/dist/index.html b/backend/internal/web/dist/index.html
index a412d90..5436cf5 100644
--- a/backend/internal/web/dist/index.html
+++ b/backend/internal/web/dist/index.html
@@ -1,3 +1,3 @@
-
-
+
+
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index e8c83d3..7fd9583 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -46,6 +46,10 @@ type Account = {
displayName: string;
email?: string;
planType?: string;
+ expectedKind: "any" | "personal" | "team";
+ actualKind: "unknown" | "personal" | "team";
+ validationStatus: "pending" | "matched" | "mismatch" | "unknown";
+ possibleDuplicate: boolean;
connected: boolean;
};
type Dash = {
@@ -358,7 +362,7 @@ function Dashboard() {
{d.displayName} · {d.account.email || "尚未连接"}
- {d.account.planType || "未识别套餐"}
+ {planLabel(d.account.planType)}
更新于{" "}
{d.fetchedAt
@@ -683,12 +687,29 @@ function CodexSettings() {
const [xs, setXs] = useState([]),
[result, setResult] = useState(null),
[active, setActive] = useState(0),
+ [newKind, setNewKind] = useState<"personal" | "team">("team"),
[busy, setBusy] = useState(false),
[err, setErr] = useState("");
- const load = () => api("accounts").then(setXs);
+ const load = () => api("accounts").then((accounts) => { setXs(accounts); return accounts; });
useEffect(() => {
load();
}, []);
+ useEffect(() => {
+ if (!result || !active) return;
+ const started = Date.now();
+ const timer = setInterval(async () => {
+ const accounts = await load();
+ const account = accounts.find((x) => x.id === active);
+ if (
+ account &&
+ ["matched", "mismatch"].includes(account.validationStatus)
+ )
+ clearInterval(timer);
+ else if (Date.now() - started >= 120000)
+ clearInterval(timer);
+ }, 2000);
+ return () => clearInterval(timer);
+ }, [result, active]);
const login = async (id: number) => {
try {
setBusy(true);
@@ -708,6 +729,7 @@ function CodexSettings() {
setErr("");
const x = await post("accounts", {
displayName: `账号 ${xs.length + 1}`,
+ expectedKind: newKind,
});
await load();
setActive(x.id);
@@ -727,6 +749,18 @@ function CodexSettings() {
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
+
@@ -742,15 +776,41 @@ function CodexSettings() {
onBlur={async (e) => {
const name = e.target.value.trim();
if (name && name !== x.displayName) {
- await put(`accounts/${x.id}`, { displayName: name });
+ await put(`accounts/${x.id}`, {
+ displayName: name,
+ expectedKind: x.expectedKind,
+ });
load();
}
}}
/>
- {x.email || "尚未登录"} · {x.planType || "未识别套餐"}
+ {x.email || "尚未登录"} · {planLabel(x.planType)}
+
+ {validationLabel(x)}
+
+ {x.possibleDuplicate && (
+
+ 同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
+
+ )}
+