feat: validate Team workspace connections
This commit is contained in:
@@ -62,6 +62,8 @@ http://服务器地址:8080
|
|||||||
|
|
||||||
如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。
|
如需添加其他账号,或同一邮箱下的个人订阅与 Team 工作区,请在“设置中心” → “Codex”中分别创建连接并完成设备码登录。每个连接使用隔离的登录凭据,可自定义名称并在总览顶部切换。删除连接会同时删除对应凭据和历史数据。
|
||||||
|
|
||||||
|
创建连接时可选择预期的“个人订阅”或“Team / Business 工作区”。授权完成后,Codex Helper 会使用 app-server 返回的真实套餐进行校验;`team` 和当前 Business 系列套餐均识别为团队工作区。设备码接口本身不能指定工作区 ID,因此同一邮箱包含多个空间时,需要在授权页面进入目标空间;如果页面提示类型不匹配,请退出该连接后重新授权。
|
||||||
|
|
||||||
## 通用设置与提醒时间
|
## 通用设置与提醒时间
|
||||||
|
|
||||||
进入“设置中心” → “通用”,可设置:
|
进入“设置中心” → “通用”,可设置:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"codex-helper/internal/security"
|
"codex-helper/internal/security"
|
||||||
|
"codex-helper/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
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":
|
case p == "accounts" && r.Method == "POST":
|
||||||
var in struct {
|
var in struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
ExpectedKind string `json:"expectedKind"`
|
||||||
}
|
}
|
||||||
if decode(r, &in) != nil {
|
if decode(r, &in) != nil {
|
||||||
jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
|
jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
|
||||||
@@ -67,7 +69,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
|
|||||||
if in.DisplayName == "" {
|
if in.DisplayName == "" {
|
||||||
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 {
|
if e == nil {
|
||||||
a.addRuntime(x.ID)
|
a.addRuntime(x.ID)
|
||||||
jsonOut(w, 201, x)
|
jsonOut(w, 201, x)
|
||||||
@@ -164,13 +173,27 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
|
|||||||
switch {
|
switch {
|
||||||
case action == "" && r.Method == "PUT":
|
case action == "" && r.Method == "PUT":
|
||||||
var in struct {
|
var in struct {
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
ExpectedKind string `json:"expectedKind"`
|
||||||
}
|
}
|
||||||
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
|
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
|
||||||
jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
|
jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
|
||||||
return
|
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 {
|
if e == nil {
|
||||||
jsonOut(w, 200, map[string]bool{"ok": true})
|
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)
|
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 {
|
var ur struct {
|
||||||
Summary UsageSummary `json:"summary"`
|
Summary UsageSummary `json:"summary"`
|
||||||
Daily []struct {
|
Daily []struct {
|
||||||
|
|||||||
@@ -135,11 +135,45 @@ func (a *App) keepCodex() {
|
|||||||
}
|
}
|
||||||
func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) {
|
func (a *App) onCodexNotification(id int64) func(string, json.RawMessage) {
|
||||||
return func(method string, _ json.RawMessage) {
|
return func(method string, _ json.RawMessage) {
|
||||||
if method == "account/updated" || method == "account/rateLimits/updated" {
|
if method == "account/login/completed" || method == "account/updated" || method == "account/rateLimits/updated" {
|
||||||
go a.syncAccount(context.Background(), id)
|
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) {
|
func (a *App) addRuntime(id int64) {
|
||||||
dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex")
|
dir := filepath.Join(a.dataDir, "accounts", strconv.FormatInt(id, 10), "codex")
|
||||||
if id == 1 {
|
if id == 1 {
|
||||||
|
|||||||
@@ -158,3 +158,26 @@ func TestDeviceLoginStartsColdRuntime(t *testing.T) {
|
|||||||
t.Fatalf("starts = %d, initializes = %d, calls = %d; want 1 each", starts, initializes, calls)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
@@ -59,12 +60,31 @@ func (s *Store) migrateAccounts() error {
|
|||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
email TEXT,
|
email TEXT,
|
||||||
plan_type TEXT,
|
plan_type TEXT,
|
||||||
|
expected_kind TEXT NOT NULL DEFAULT 'any',
|
||||||
connected INTEGER NOT NULL DEFAULT 0,
|
connected INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)`); err != nil {
|
)`); err != nil {
|
||||||
return err
|
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
|
var count int
|
||||||
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
|
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -122,17 +142,21 @@ func (s *Store) migrateAccounts() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
PlanType *string `json:"planType"`
|
PlanType *string `json:"planType"`
|
||||||
Connected bool `json:"connected"`
|
ExpectedKind string `json:"expectedKind"`
|
||||||
CreatedAt int64 `json:"createdAt"`
|
ActualKind string `json:"actualKind"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
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) {
|
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 {
|
if e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
@@ -140,24 +164,40 @@ func (s *Store) Accounts() ([]Account, error) {
|
|||||||
out := []Account{}
|
out := []Account{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a Account
|
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
|
return nil, e
|
||||||
}
|
}
|
||||||
|
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
|
||||||
out = append(out, a)
|
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()
|
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()
|
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 {
|
if e != nil {
|
||||||
return Account{}, e
|
return Account{}, e
|
||||||
}
|
}
|
||||||
id, _ := r.LastInsertId()
|
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 {
|
func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error {
|
||||||
r, e := s.DB.Exec("UPDATE accounts SET display_name=?,updated_at=? WHERE id=?", name, time.Now().Unix(), id)
|
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 {
|
if e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -167,6 +207,45 @@ func (s *Store) RenameAccount(id int64, name string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 {
|
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)
|
_, e := s.DB.Exec("UPDATE accounts SET email=?,plan_type=?,connected=?,updated_at=? WHERE id=?", email, plan, connected, time.Now().Unix(), id)
|
||||||
return e
|
return e
|
||||||
|
|||||||
@@ -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) {
|
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db"))
|
db, err := sql.Open("sqlite", filepath.Join(dir, "codex-helper.db"))
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,3 +1,3 @@
|
|||||||
<script type="module" crossorigin src="/assets/index-DhESEhcz.js"></script>
|
<script type="module" crossorigin src="/assets/index-BbkhVAJw.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BpH_Y97H.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BTO6fDiH.css">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+82
-4
@@ -46,6 +46,10 @@ type Account = {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
planType?: string;
|
planType?: string;
|
||||||
|
expectedKind: "any" | "personal" | "team";
|
||||||
|
actualKind: "unknown" | "personal" | "team";
|
||||||
|
validationStatus: "pending" | "matched" | "mismatch" | "unknown";
|
||||||
|
possibleDuplicate: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
};
|
};
|
||||||
type Dash = {
|
type Dash = {
|
||||||
@@ -358,7 +362,7 @@ function Dashboard() {
|
|||||||
{d.displayName} · {d.account.email || "尚未连接"}
|
{d.displayName} · {d.account.email || "尚未连接"}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge">{d.account.planType || "未识别套餐"}</span>
|
<span className="badge">{planLabel(d.account.planType)}</span>
|
||||||
<span className="fresh">
|
<span className="fresh">
|
||||||
更新于{" "}
|
更新于{" "}
|
||||||
{d.fetchedAt
|
{d.fetchedAt
|
||||||
@@ -683,12 +687,29 @@ function CodexSettings() {
|
|||||||
const [xs, setXs] = useState<Account[]>([]),
|
const [xs, setXs] = useState<Account[]>([]),
|
||||||
[result, setResult] = useState<any>(null),
|
[result, setResult] = useState<any>(null),
|
||||||
[active, setActive] = useState(0),
|
[active, setActive] = useState(0),
|
||||||
|
[newKind, setNewKind] = useState<"personal" | "team">("team"),
|
||||||
[busy, setBusy] = useState(false),
|
[busy, setBusy] = useState(false),
|
||||||
[err, setErr] = useState("");
|
[err, setErr] = useState("");
|
||||||
const load = () => api<Account[]>("accounts").then(setXs);
|
const load = () => api<Account[]>("accounts").then((accounts) => { setXs(accounts); return accounts; });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
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) => {
|
const login = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -708,6 +729,7 @@ function CodexSettings() {
|
|||||||
setErr("");
|
setErr("");
|
||||||
const x = await post<Account>("accounts", {
|
const x = await post<Account>("accounts", {
|
||||||
displayName: `账号 ${xs.length + 1}`,
|
displayName: `账号 ${xs.length + 1}`,
|
||||||
|
expectedKind: newKind,
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
setActive(x.id);
|
setActive(x.id);
|
||||||
@@ -727,6 +749,18 @@ function CodexSettings() {
|
|||||||
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
|
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<label>
|
||||||
|
新连接类型
|
||||||
|
<select
|
||||||
|
value={newKind}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewKind(e.target.value as "personal" | "team")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="team">Team / Business 工作区</option>
|
||||||
|
<option value="personal">个人订阅</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<button disabled={busy} onClick={add}>
|
<button disabled={busy} onClick={add}>
|
||||||
{busy && active === 0 ? "服务启动中…" : "添加账号"}
|
{busy && active === 0 ? "服务启动中…" : "添加账号"}
|
||||||
</button>
|
</button>
|
||||||
@@ -742,15 +776,41 @@ function CodexSettings() {
|
|||||||
onBlur={async (e) => {
|
onBlur={async (e) => {
|
||||||
const name = e.target.value.trim();
|
const name = e.target.value.trim();
|
||||||
if (name && name !== x.displayName) {
|
if (name && name !== x.displayName) {
|
||||||
await put(`accounts/${x.id}`, { displayName: name });
|
await put(`accounts/${x.id}`, {
|
||||||
|
displayName: name,
|
||||||
|
expectedKind: x.expectedKind,
|
||||||
|
});
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<small>
|
<small>
|
||||||
{x.email || "尚未登录"} · {x.planType || "未识别套餐"}
|
{x.email || "尚未登录"} · {planLabel(x.planType)}
|
||||||
</small>
|
</small>
|
||||||
|
<small className={x.validationStatus === "mismatch" ? "error" : ""}>
|
||||||
|
{validationLabel(x)}
|
||||||
|
</small>
|
||||||
|
{x.possibleDuplicate && (
|
||||||
|
<small className="hint">
|
||||||
|
同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
aria-label="预期连接类型"
|
||||||
|
value={x.expectedKind}
|
||||||
|
onChange={async (e) => {
|
||||||
|
await put(`accounts/${x.id}`, {
|
||||||
|
displayName: x.displayName,
|
||||||
|
expectedKind: e.target.value,
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="any">不校验</option>
|
||||||
|
<option value="personal">个人订阅</option>
|
||||||
|
<option value="team">Team / Business</option>
|
||||||
|
</select>
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -1056,6 +1116,24 @@ const duration = (v?: number) =>
|
|||||||
: v < 3600
|
: v < 3600
|
||||||
? `${Math.floor(v / 60)} 分 ${v % 60} 秒`
|
? `${Math.floor(v / 60)} 分 ${v % 60} 秒`
|
||||||
: `${(v / 3600).toFixed(1)} 小时`;
|
: `${(v / 3600).toFixed(1)} 小时`;
|
||||||
|
const planLabel = (plan?: string) => {
|
||||||
|
if (!plan) return "未识别套餐";
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
free: "Free", go: "Go", plus: "Plus", pro: "Pro", prolite: "Pro Lite",
|
||||||
|
team: "Team", business: "Business",
|
||||||
|
self_serve_business_prolite: "Business Pro Lite",
|
||||||
|
self_serve_business_usage_based: "Business(按量)",
|
||||||
|
enterprise: "Enterprise", edu: "Edu",
|
||||||
|
};
|
||||||
|
return labels[plan.toLowerCase()] || plan;
|
||||||
|
};
|
||||||
|
const validationLabel = (account: Account) => {
|
||||||
|
if (account.expectedKind === "any") return "未启用套餐类型校验";
|
||||||
|
if (account.validationStatus === "pending") return `等待验证${account.expectedKind === "team" ? " Team / Business" : "个人订阅"}`;
|
||||||
|
if (account.validationStatus === "matched") return account.actualKind === "team" ? "已连接 Team / Business 工作区" : "已连接个人订阅";
|
||||||
|
if (account.validationStatus === "mismatch") return `类型不匹配:实际为 ${planLabel(account.planType)},请退出后重新授权`;
|
||||||
|
return `无法验证工作区类型:${planLabel(account.planType)}`;
|
||||||
|
};
|
||||||
const relative = (ts: number) => {
|
const relative = (ts: number) => {
|
||||||
const s = Math.max(0, ts - Math.floor(Date.now() / 1000));
|
const s = Math.max(0, ts - Math.floor(Date.now() / 1000));
|
||||||
if (!s) return "即将重置";
|
if (!s) return "即将重置";
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ header p {
|
|||||||
}
|
}
|
||||||
.account-row {
|
.account-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(180px, 1fr) auto auto auto;
|
grid-template-columns: auto minmax(180px, 1fr) auto auto auto auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
|||||||
@@ -12,7 +12,17 @@ const responses: Record<string, unknown> = {
|
|||||||
notifyBefore: true,
|
notifyBefore: true,
|
||||||
notifyAfter: true,
|
notifyAfter: true,
|
||||||
},
|
},
|
||||||
accounts: [{ id: 1, displayName: "默认账号", email: "test@example.com", planType: "Plus", connected: true }],
|
accounts: [{
|
||||||
|
id: 1,
|
||||||
|
displayName: "默认账号",
|
||||||
|
email: "test@example.com",
|
||||||
|
planType: "plus",
|
||||||
|
expectedKind: "personal",
|
||||||
|
actualKind: "personal",
|
||||||
|
validationStatus: "matched",
|
||||||
|
possibleDuplicate: false,
|
||||||
|
connected: true,
|
||||||
|
}],
|
||||||
"settings/telegram": { token: "", configured: true, enabled: true, menuEnabled: true },
|
"settings/telegram": { token: "", configured: true, enabled: true, menuEnabled: true },
|
||||||
"settings/smtp": {
|
"settings/smtp": {
|
||||||
host: "smtp.example.com",
|
host: "smtp.example.com",
|
||||||
|
|||||||
Reference in New Issue
Block a user