feat: add public account overview visibility

This commit is contained in:
2026-08-14 14:38:22 +08:00
parent bf37999833
commit 915d26d51d
14 changed files with 660 additions and 62 deletions
+7 -6
View File
@@ -8,7 +8,7 @@
- `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。
- JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。
- 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。
- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用。status 的其他方法返回 405;其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。
- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用;初始化完成后,`GET`/`HEAD /api/v1/accounts` 和 `GET`/`HEAD /api/v1/dashboard` 也提供已标记为公开账号的匿名只读总览。匿名总览会隐藏邮箱、认证方式、账号配置校验和内部错误字段;未公开账号对匿名请求按不存在处理。其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。
- session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。
- 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。
@@ -18,7 +18,7 @@
```text
{id, displayName, email:string|null, planType:string|null,
expectedKind:"any"|"personal"|"team",
expectedKind:"any"|"personal"|"team", publicVisible:bool,
actualKind:"unknown"|"personal"|"team",
validationStatus:"pending"|"matched"|"mismatch"|"unknown",
possibleDuplicate:bool, connected:bool, createdAt, updatedAt}
@@ -57,14 +57,15 @@
| 方法与路径 | 请求与响应 |
| --- | --- |
| `GET /api/v1/accounts` | 返回 `200 Account[]`,按 ID 升序。 |
| `POST /api/v1/accounts` | body `{displayName,expectedKind}`;空名称默认为 `新账号`,空类型默认为 `any`;成功返回 `201 Account`。 |
| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?}`;名称不能为空,省略类型时保留旧值;成功返回 `200 {ok:true}`。 |
| `GET /api/v1/accounts` | 初始化后匿名可读;匿名只返回 `publicVisible=true` 的账号,按 ID 升序;匿名响应隐藏 `email`、`expectedKind`、`actualKind`、`validationStatus`、`possibleDuplicate` 和创建/更新时间。登录后返回全部账号及完整字段。 |
| `POST /api/v1/accounts` | body `{displayName,expectedKind,publicVisible}`;空名称默认为 `新账号`,空类型默认为 `any`,`publicVisible` 省略时默认为 `false`;成功返回 `201 Account`。 |
| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?,publicVisible?}`;名称不能为空,省略类型或 `publicVisible` 时分别保留旧值;成功返回 `200 {ok:true}`。 |
| `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 |
| `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 |
| `任意方法 /api/v1/dashboard?accountId={id}` | 返回内存中的 `Dashboard`;非 `GET`/`HEAD` 还需来源头。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
| `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
| `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 |
| `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 |
账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。
+104 -32
View File
@@ -43,6 +43,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
a.login(w, r)
return
}
if p == "accounts" && readOnlyMethod(r.Method) {
a.accountsAPI(w, r)
return
}
if p == "dashboard" && readOnlyMethod(r.Method) {
a.dashboardAPI(w, r)
return
}
if !a.require(w, r) {
return
}
@@ -53,17 +61,11 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]string{"username": username})
case p == "auth/logout" && r.Method == "POST":
a.logout(w, r)
case p == "accounts" && r.Method == "GET":
x, e := a.store.Accounts()
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
} else {
jsonOut(w, 200, x)
}
case p == "accounts" && r.Method == "POST":
var in struct {
DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"`
DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
@@ -80,7 +82,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
break
}
x, e := a.store.CreateAccount(in.DisplayName, in.ExpectedKind)
x, e := a.store.CreateAccountWithVisibility(in.DisplayName, in.ExpectedKind, in.PublicVisible)
if e == nil {
a.addRuntime(x.ID)
jsonOut(w, 201, x)
@@ -90,25 +92,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(p, "accounts/"):
a.accountAPI(w, r, p)
case p == "dashboard":
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
id = 1
}
rt := a.runtime(id)
if rt == nil {
jsonOut(w, 404, map[string]string{"error": "账号不存在"})
} else {
rt.syncing.Lock()
d := rt.dash
rt.syncing.Unlock()
if d.Limits == nil {
d.Limits = []LimitBucket{}
}
if d.Usage == nil {
d.Usage = []UsagePoint{}
}
jsonOut(w, 200, d)
}
a.dashboardAPI(w, r)
case p == "sync" && r.Method == "POST":
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
@@ -162,6 +146,93 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
}
}
func readOnlyMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
func (a *App) accountsAPI(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"})
return
}
x, e := a.store.Accounts()
if e != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": e.Error()})
return
}
if !a.authed(r) {
visible := make([]store.Account, 0, len(x))
for _, account := range x {
if !account.PublicVisible {
continue
}
visible = append(visible, publicAccount(account))
}
x = visible
}
jsonOut(w, http.StatusOK, x)
}
func publicAccount(account store.Account) store.Account {
account.Email = nil
account.ExpectedKind = "any"
account.ActualKind = "unknown"
account.ValidationStatus = "unknown"
account.PossibleDuplicate = false
account.CreatedAt = 0
account.UpdatedAt = 0
return account
}
func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"})
return
}
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
id = 1
}
account, accountErr := a.store.Account(id)
if accountErr != nil {
if accountErr == sql.ErrNoRows {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
} else {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": accountErr.Error()})
}
return
}
if !a.authed(r) && !account.PublicVisible {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
return
}
rt := a.runtime(id)
if rt == nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
return
}
rt.syncing.Lock()
d := rt.dash
rt.syncing.Unlock()
if d.Limits == nil {
d.Limits = []LimitBucket{}
}
if d.Usage == nil {
d.Usage = []UsagePoint{}
}
if !a.authed(r) {
d = publicDashboard(d)
}
jsonOut(w, http.StatusOK, d)
}
func publicDashboard(d Dashboard) Dashboard {
d.Account.Email = nil
d.Account.AuthMode = nil
d.LastError = ""
return d
}
func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
parts := strings.Split(p, "/")
if len(parts) < 2 {
@@ -185,8 +256,9 @@ 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"`
ExpectedKind string `json:"expectedKind"`
DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"`
PublicVisible *bool `json:"publicVisible"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
@@ -205,7 +277,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
return
}
e = a.store.UpdateAccountSettings(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind)
e = a.store.UpdateAccountSettingsWithVisibility(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind, in.PublicVisible)
if e == nil {
jsonOut(w, 200, map[string]bool{"ok": true})
}
+208
View File
@@ -6,11 +6,14 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"codex-helper/internal/security"
"codex-helper/internal/store"
)
func TestSystemStatusRejectsNonGETMethods(t *testing.T) {
@@ -47,6 +50,9 @@ func TestSystemStatusReturnsBuildVersion(t *testing.T) {
func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
a.runtimes[1] = &accountRuntime{}
_, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix())
if err != nil {
@@ -71,6 +77,208 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
}
}
func TestAnonymousOverviewIsReadOnly(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
email := "owner@example.com"
plan := "plus"
if err := a.store.UpdateAccount(1, &email, &plan, true); err != nil {
t.Fatal(err)
}
publicVisible := true
if err := a.store.UpdateAccountSettingsWithVisibility(1, "默认账号", "any", &publicVisible); err != nil {
t.Fatal(err)
}
a.runtimes[1] = &accountRuntime{
dash: Dashboard{
AccountID: 1,
DisplayName: "默认账号",
Account: AccountView{Email: &email, PlanType: &plan, Connected: true},
Limits: []LimitBucket{},
Usage: []UsagePoint{},
FetchedAt: time.Now().Unix(),
},
}
accountsRecorder := httptest.NewRecorder()
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
if accountsRecorder.Code != http.StatusOK {
t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String())
}
var accounts []struct {
Email *string `json:"email"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
ValidationState string `json:"validationStatus"`
}
if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &accounts); err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].Email != nil || !accounts[0].PublicVisible || accounts[0].ExpectedKind != "any" || accounts[0].ValidationState != "unknown" {
t.Fatalf("anonymous account data = %#v; sensitive account fields were not redacted", accounts)
}
dashboardRecorder := httptest.NewRecorder()
a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil))
if dashboardRecorder.Code != http.StatusOK {
t.Fatalf("anonymous dashboard status = %d, body = %s", dashboardRecorder.Code, dashboardRecorder.Body.String())
}
var publicDashboardBody Dashboard
if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &publicDashboardBody); err != nil {
t.Fatal(err)
}
if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil {
t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account)
}
for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} {
recorder := httptest.NewRecorder()
method := http.MethodGet
if path == "/api/v1/accounts" || strings.HasSuffix(path, "/sync") {
method = http.MethodPost
}
a.api(recorder, httptest.NewRequest(method, path, nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("anonymous %s status = %d, body = %s; configuration must require login", path, recorder.Code, recorder.Body.String())
}
}
session := "test-session"
if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
t.Fatal(err)
}
privateRecorder := httptest.NewRecorder()
privateRequest := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil)
privateRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
a.api(privateRecorder, privateRequest)
if privateRecorder.Code != http.StatusOK {
t.Fatalf("authenticated dashboard status = %d, body = %s", privateRecorder.Code, privateRecorder.Body.String())
}
var privateDashboardBody Dashboard
if err := json.Unmarshal(privateRecorder.Body.Bytes(), &privateDashboardBody); err != nil {
t.Fatal(err)
}
if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email {
t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email)
}
configRecorder := httptest.NewRecorder()
configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`))
configRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
configRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(configRecorder, configRequest)
if configRecorder.Code != http.StatusOK {
t.Fatalf("authenticated settings status = %d, body = %s", configRecorder.Code, configRecorder.Body.String())
}
}
func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "team", true)
if err != nil {
t.Fatal(err)
}
privateAccount, err := a.store.CreateAccount("私有账号", "personal")
if err != nil {
t.Fatal(err)
}
a.runtimes[publicAccount.ID] = &accountRuntime{}
a.runtimes[privateAccount.ID] = &accountRuntime{}
accountsRecorder := httptest.NewRecorder()
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
if accountsRecorder.Code != http.StatusOK {
t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String())
}
var visible []struct {
ID int64 `json:"id"`
PublicVisible bool `json:"publicVisible"`
}
if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &visible); err != nil {
t.Fatal(err)
}
if len(visible) != 1 || visible[0].ID != publicAccount.ID || !visible[0].PublicVisible {
t.Fatalf("anonymous accounts = %#v; want only public account %d", visible, publicAccount.ID)
}
privateDashboard := httptest.NewRecorder()
privatePath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(privateAccount.ID, 10)
a.api(privateDashboard, httptest.NewRequest(http.MethodGet, privatePath, nil))
if privateDashboard.Code != http.StatusNotFound {
t.Fatalf("anonymous private dashboard status = %d, body = %s", privateDashboard.Code, privateDashboard.Body.String())
}
publicDashboard := httptest.NewRecorder()
publicPath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(publicAccount.ID, 10)
a.api(publicDashboard, httptest.NewRequest(http.MethodGet, publicPath, nil))
if publicDashboard.Code != http.StatusOK {
t.Fatalf("anonymous public dashboard status = %d, body = %s", publicDashboard.Code, publicDashboard.Body.String())
}
session := "visibility-session"
if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
t.Fatal(err)
}
createRecorder := httptest.NewRecorder()
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/accounts", strings.NewReader(`{"displayName":"接口公开账号","expectedKind":"team","publicVisible":true}`))
createRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
createRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("authenticated account creation status = %d, body = %s", createRecorder.Code, createRecorder.Body.String())
}
var created store.Account
if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if !created.PublicVisible {
t.Fatalf("created account = %#v; want publicVisible=true", created)
}
authenticatedAccounts := httptest.NewRecorder()
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)
authenticatedRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
a.api(authenticatedAccounts, authenticatedRequest)
if authenticatedAccounts.Code != http.StatusOK {
t.Fatalf("authenticated accounts status = %d, body = %s", authenticatedAccounts.Code, authenticatedAccounts.Body.String())
}
var all []struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(authenticatedAccounts.Body.Bytes(), &all); err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Fatalf("authenticated accounts = %#v; want default, public, private, and newly created accounts", all)
}
anonymousUpdate := httptest.NewRecorder()
anonymousUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
a.api(anonymousUpdate, anonymousUpdateRequest)
if anonymousUpdate.Code != http.StatusUnauthorized {
t.Fatalf("anonymous visibility update status = %d, body = %s", anonymousUpdate.Code, anonymousUpdate.Body.String())
}
authenticatedUpdate := httptest.NewRecorder()
authenticatedUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
authenticatedUpdateRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
authenticatedUpdateRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(authenticatedUpdate, authenticatedUpdateRequest)
if authenticatedUpdate.Code != http.StatusOK {
t.Fatalf("authenticated visibility update status = %d, body = %s", authenticatedUpdate.Code, authenticatedUpdate.Body.String())
}
updated, err := a.store.Account(privateAccount.ID)
if err != nil {
t.Fatal(err)
}
if !updated.PublicVisible {
t.Fatalf("updated account = %#v; want publicVisible=true", updated)
}
}
func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
+44 -6
View File
@@ -95,13 +95,14 @@ func (s *Store) migrateAccounts() error {
email TEXT,
plan_type TEXT,
expected_kind TEXT NOT NULL DEFAULT 'any',
public_visible INTEGER NOT NULL DEFAULT 0,
connected INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`); err != nil {
return err
}
var hasExpectedKind bool
var hasExpectedKind, hasPublicVisible bool
rows, qerr := tx.Query("PRAGMA table_info(accounts)")
if qerr != nil {
return qerr
@@ -112,6 +113,7 @@ func (s *Store) migrateAccounts() error {
var def any
_ = rows.Scan(&cid, &name, &typ, &notnull, &def, &pk)
hasExpectedKind = hasExpectedKind || name == "expected_kind"
hasPublicVisible = hasPublicVisible || name == "public_visible"
}
rows.Close()
if !hasExpectedKind {
@@ -119,6 +121,11 @@ func (s *Store) migrateAccounts() error {
return err
}
}
if !hasPublicVisible {
if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN public_visible INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
}
var count int
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
return err
@@ -181,6 +188,7 @@ type Account struct {
Email *string `json:"email"`
PlanType *string `json:"planType"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
ActualKind string `json:"actualKind"`
ValidationStatus string `json:"validationStatus"`
PossibleDuplicate bool `json:"possibleDuplicate"`
@@ -190,7 +198,7 @@ type Account struct {
}
func (s *Store) Accounts() ([]Account, error) {
rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,connected,created_at,updated_at FROM accounts ORDER BY id")
rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts ORDER BY id")
if e != nil {
return nil, e
}
@@ -198,7 +206,7 @@ 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.ExpectedKind, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &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)
@@ -217,21 +225,51 @@ func (s *Store) Accounts() ([]Account, error) {
}
return out, rows.Err()
}
func (s *Store) Account(id int64) (Account, error) {
var a Account
err := s.DB.QueryRow("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts WHERE id=?", id).
Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return Account{}, err
}
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
return a, nil
}
func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) {
return s.createAccount(name, false, kinds...)
}
func (s *Store) CreateAccountWithVisibility(name, expectedKind string, publicVisible bool) (Account, error) {
return s.createAccount(name, publicVisible, expectedKind)
}
func (s *Store) createAccount(name string, publicVisible bool, 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,expected_kind,created_at,updated_at) VALUES(?,?,?,?)", name, expectedKind, now, now)
r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,public_visible,created_at,updated_at) VALUES(?,?,?,?,?)", name, expectedKind, publicVisible, now, now)
if e != nil {
return Account{}, e
}
id, _ := r.LastInsertId()
return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil
return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, PublicVisible: publicVisible, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil
}
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)
return s.UpdateAccountSettingsWithVisibility(id, name, expectedKind, nil)
}
func (s *Store) UpdateAccountSettingsWithVisibility(id int64, name, expectedKind string, publicVisible *bool) error {
var r sql.Result
var e error
if publicVisible == nil {
r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id)
} else {
r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, expectedKind, *publicVisible, time.Now().Unix(), id)
}
if e != nil {
return e
}
+43 -2
View File
@@ -33,7 +33,7 @@ func TestAccountsAndPerAccountUsage(t *testing.T) {
}
defer s.DB.Close()
accounts, err := s.Accounts()
if err != nil || len(accounts) != 1 || accounts[0].ID != 1 {
if err != nil || len(accounts) != 1 || accounts[0].ID != 1 || accounts[0].PublicVisible {
t.Fatalf("default accounts = %#v, %v", accounts, err)
}
second, err := s.CreateAccount("Team workspace")
@@ -97,11 +97,52 @@ func TestExistingAccountsGainExpectedKind(t *testing.T) {
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" {
if accounts[0].ExpectedKind != "any" || accounts[0].PublicVisible || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" {
t.Fatalf("migrated account = %#v", accounts[0])
}
}
func TestAccountVisibilitySettings(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.DB.Close()
private, err := s.CreateAccount("私有账号")
if err != nil {
t.Fatal(err)
}
public, err := s.CreateAccountWithVisibility("公开账号", "team", true)
if err != nil {
t.Fatal(err)
}
if private.PublicVisible || !public.PublicVisible {
t.Fatalf("created accounts = %#v, %#v", private, public)
}
if err := s.UpdateAccountSettings(public.ID, "公开账号重命名", "team"); err != nil {
t.Fatal(err)
}
unchanged, err := s.Account(public.ID)
if err != nil {
t.Fatal(err)
}
if !unchanged.PublicVisible {
t.Fatal("legacy settings update unexpectedly changed public visibility")
}
visible := false
if err := s.UpdateAccountSettingsWithVisibility(public.ID, "公开账号重命名", "team", &visible); err != nil {
t.Fatal(err)
}
updated, err := s.Account(public.ID)
if err != nil {
t.Fatal(err)
}
if updated.PublicVisible {
t.Fatal("explicit false visibility update was not persisted")
}
}
func ptr(value string) *string { return &value }
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {