feat: add config-driven super-admin bootstrap to Go backend

This commit is contained in:
2026-08-14 07:43:05 +08:00
parent 8affa4b25a
commit ff055c972d
5 changed files with 345 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
# Task: Implement config-driven super-admin bootstrap in Go
## Identity
- Task ID: 20260814-go-bootstrap-admin-6e2b7d9c
- Mode: Feature
- Branch: main
- Worktree: /Users/brother7/Documents/AI/NianAIGC
- Base commit: 8affa4b25a93515547a5412d0cbd79dd9fcdc034
- Owner: dsh
- Status: Ready for Integration
## Scope
- Add config-driven first-super-administrator bootstrap to the Go backend, replacing the previous `scripts/bootstrap-admin.mjs` convention with startup-time creation from environment configuration.
- Human direction (2026-08-14): bootstrap credentials come from configuration, not a script or CLI step; there is no legacy account import requirement; production is a first deployment of the Go stack, not a cutover from a live Next.js deployment.
## Intent And Constraints
- Bootstrap only when the backend is PostgreSQL; local development keeps the seeded demo administrator.
- Create exactly once: skip when any super administrator exists, including disabled ones.
- Fail application startup on a misconfigured bootstrap (invalid phone, password shorter than 8 characters, database error) so the problem is visible instead of silently missing.
- Reuse the existing `administration.Service` validation, role model, and password hashing; no new store surface.
## Outcome
- Added `backend/internal/application/bootstrap_admin.go`:
- `BootstrapAdminConfig` with `Configured()`.
- `ParseBootstrapAdminConfig(getenv)` reading `ZHINIAN_BOOTSTRAP_ADMIN_PHONE`, `ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD`, `ZHINIAN_BOOTSTRAP_ADMIN_NAME` (default `平台超级管理员`), mirroring the previous script variable names.
- `BootstrapSuperAdmin(ctx, backend, service, config)` performing the idempotent creation.
- Wired into `application.New` right after the administration service is composed; a successful bootstrap logs one line, a failure aborts startup with `bootstrap super administrator: ...`.
- Added `backend/internal/application/bootstrap_admin_test.go` with an in-memory fake store covering: config parsing/defaults, no-op without config, no-op on local backend, first-bootstrap creation with hashed password, second-run idempotency, skip when a disabled super admin exists, and short-password rejection.
- Documented the three variables and the startup behavior in `backend/README.md`.
## Verification
- `CGO_ENABLED=0 go test -count=1 ./...` — all 19 packages PASS (the local plain `go test` crashes with a macOS dyld `missing LC_UUID load command` issue unrelated to this change; the repository runner always uses `CGO_ENABLED=0`).
- `CGO_ENABLED=0 go vet ./...` — PASS.
- `CGO_ENABLED=0 go build ./cmd/zhinian-api` — PASS.
- `CGO_ENABLED=0 go test -race -count=1 ./internal/application/ -run Bootstrap` — PASS.
## Follow-ups
- Production schema initialization now happens by manually executed SQL instead of the ACK migration Job pod; see the 2026-08-14 human direction. Deployment guidance and canonical memory reconciliation for that change are tracked in the follow-up integration task.
## Promotion Candidates
- None; no canonical document changes in this task.

View File

@@ -50,6 +50,23 @@ restart; it is intended only for development and contract smoke tests. The
default demo identity is the same optional-auth super administrator used by the
current Next development flow.
## First super administrator
On the first PostgreSQL deployment, the first super administrator is created
from configuration at startup — no separate script or CLI step is needed. When
the three variables below are all present and the process runs against
PostgreSQL, startup creates the account exactly once and skips the bootstrap
when any super administrator (including a disabled one) already exists:
- `ZHINIAN_BOOTSTRAP_ADMIN_PHONE`
- `ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD` (minimum 8 characters)
- `ZHINIAN_BOOTSTRAP_ADMIN_NAME` (defaults to `平台超级管理员`)
A failed bootstrap (invalid phone, short password, database error) fails
process startup so a misconfigured bootstrap is visible instead of silently
missing. Local development mode keeps the seeded demo administrator and never
bootstrap-creates accounts.
No Ingress, Docker, ACK, Secret, or Worker ownership has moved to Go yet, so
Next.js remains the deployed owner of every route. Real RDS/CA, OSS, provider,
Webhook, Worker drain/recovery, and rollback validation are mandatory before

View File

@@ -3,6 +3,8 @@ package application
import (
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
@@ -176,6 +178,11 @@ func New(options Options) (*App, error) {
})
administrationService := administration.NewService(administrationStore)
if created, err := BootstrapSuperAdmin(ctx, config.Backend, administrationService, ParseBootstrapAdminConfig(getenv)); err != nil {
return nil, fmt.Errorf("bootstrap super administrator: %w", err)
} else if created {
log.Printf("zhinian-api bootstrapped the first super administrator from ZHINIAN_BOOTSTRAP_ADMIN_* configuration")
}
adminHandler, err := httpapi.NewAdminHandler(platformAuthorizer, administrationService)
if err != nil {
return nil, err

View File

@@ -0,0 +1,67 @@
package application
import (
"context"
"strings"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
)
// BootstrapAdminConfig describes the config-driven first super administrator.
// When every field is present and the process runs against PostgreSQL,
// application startup creates this account exactly once and skips the
// bootstrap when a super administrator already exists.
type BootstrapAdminConfig struct {
Phone string
Password string
DisplayName string
}
// Configured reports whether every bootstrap field is present.
func (c BootstrapAdminConfig) Configured() bool {
return c.Phone != "" && c.Password != "" && c.DisplayName != ""
}
// ParseBootstrapAdminConfig reads ZHINIAN_BOOTSTRAP_ADMIN_PHONE,
// ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD, and ZHINIAN_BOOTSTRAP_ADMIN_NAME. The
// variable names mirror the previous scripts/bootstrap-admin.mjs contract;
// the display name defaults to 平台超级管理员.
func ParseBootstrapAdminConfig(getenv func(string) string) BootstrapAdminConfig {
if getenv == nil {
return BootstrapAdminConfig{}
}
return BootstrapAdminConfig{
Phone: strings.TrimSpace(getenv("ZHINIAN_BOOTSTRAP_ADMIN_PHONE")),
Password: getenv("ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD"),
DisplayName: firstNonEmpty(strings.TrimSpace(getenv("ZHINIAN_BOOTSTRAP_ADMIN_NAME")), "平台超级管理员"),
}
}
// BootstrapSuperAdmin creates the configured first super administrator once.
// It is a no-op when the backend is not PostgreSQL, when the configuration is
// incomplete, or when a super administrator already exists (including
// disabled ones, so a retired administrator cannot trigger a duplicate).
// Failures are returned to the caller and fail application startup, so a
// misconfigured bootstrap is visible instead of silently missing.
func BootstrapSuperAdmin(ctx context.Context, backend postgres.Backend, service *administration.Service, config BootstrapAdminConfig) (bool, error) {
if backend != postgres.BackendPostgres || !config.Configured() {
return false, nil
}
existing, err := service.ListAccounts(ctx, administration.Actor{Role: administration.RoleSuperAdmin}, administration.AccountFilters{Role: administration.RoleSuperAdmin, IncludeDisabled: true})
if err != nil {
return false, err
}
if len(existing) > 0 {
return false, nil
}
if _, err := service.CreateAccount(ctx, administration.Actor{Role: administration.RoleSuperAdmin}, administration.CreateAccountInput{
Phone: config.Phone,
DisplayName: config.DisplayName,
Password: config.Password,
Role: administration.RoleSuperAdmin,
}); err != nil {
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,206 @@
package application
import (
"context"
"errors"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
)
// bootstrapFakeStore is a minimal in-memory administration.Store for
// bootstrap tests. Only ListAccounts and CreateAccount carry behavior; the
// remaining methods exist to satisfy the interface.
type bootstrapFakeStore struct {
accounts map[string]administration.Account
}
func newBootstrapFakeStore() *bootstrapFakeStore {
return &bootstrapFakeStore{accounts: map[string]administration.Account{}}
}
func (s *bootstrapFakeStore) ListAccounts(_ context.Context, filters administration.AccountFilters) ([]administration.Account, error) {
out := []administration.Account{}
for _, account := range s.accounts {
if filters.OrganizationID != "" && account.OrganizationID != filters.OrganizationID {
continue
}
if filters.Role != "" && account.Role != filters.Role {
continue
}
if !filters.IncludeDisabled && account.Status != administration.StatusActive {
continue
}
out = append(out, account)
}
return out, nil
}
func (s *bootstrapFakeStore) GetAccount(_ context.Context, id string) (administration.Account, bool, error) {
account, ok := s.accounts[id]
return account, ok, nil
}
func (s *bootstrapFakeStore) CreateAccount(_ context.Context, account administration.Account) (administration.Account, error) {
if _, exists := s.accounts[account.ID]; exists {
return administration.Account{}, errors.New("account already exists")
}
s.accounts[account.ID] = account
return account, nil
}
func (s *bootstrapFakeStore) UpdateAccount(_ context.Context, account administration.Account) (administration.Account, error) {
s.accounts[account.ID] = account
return account, nil
}
func (s *bootstrapFakeStore) DeleteAccount(_ context.Context, id, _ string) error {
delete(s.accounts, id)
return nil
}
func (s *bootstrapFakeStore) ListOrganizations(context.Context, bool) ([]administration.Organization, error) {
return nil, nil
}
func (s *bootstrapFakeStore) GetOrganization(context.Context, string) (administration.Organization, bool, error) {
return administration.Organization{}, false, nil
}
func (s *bootstrapFakeStore) CreateOrganization(_ context.Context, organization administration.Organization) (administration.Organization, error) {
return organization, nil
}
func (s *bootstrapFakeStore) UpdateOrganization(_ context.Context, organization administration.Organization) (administration.Organization, error) {
return organization, nil
}
func (s *bootstrapFakeStore) DeleteOrganization(context.Context, string) error { return nil }
func (s *bootstrapFakeStore) CountOrganizationMembers(context.Context, string) (int, error) {
return 0, nil
}
func configuredBootstrap() BootstrapAdminConfig {
return BootstrapAdminConfig{Phone: "13800138000", Password: "change-me-now", DisplayName: "平台超级管理员"}
}
func TestParseBootstrapAdminConfig(t *testing.T) {
env := map[string]string{
"ZHINIAN_BOOTSTRAP_ADMIN_PHONE": " 13800138000 ",
"ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD": "change-me-now",
"ZHINIAN_BOOTSTRAP_ADMIN_NAME": " 平台超级管理员 ",
}
config := ParseBootstrapAdminConfig(func(name string) string { return env[name] })
if config.Phone != "13800138000" || config.Password != "change-me-now" || config.DisplayName != "平台超级管理员" {
t.Fatalf("unexpected parsed config: %+v", config)
}
if !config.Configured() {
t.Fatalf("expected parsed config to be complete")
}
defaulted := ParseBootstrapAdminConfig(func(name string) string {
if name == "ZHINIAN_BOOTSTRAP_ADMIN_NAME" {
return ""
}
return env[name]
})
if defaulted.DisplayName != "平台超级管理员" {
t.Fatalf("expected default display name, got %q", defaulted.DisplayName)
}
if config := ParseBootstrapAdminConfig(nil); config.Configured() {
t.Fatalf("nil getenv must produce an incomplete config")
}
}
func TestBootstrapSuperAdminNoopWithoutConfig(t *testing.T) {
store := newBootstrapFakeStore()
service := administration.NewService(store)
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, BootstrapAdminConfig{})
if err != nil || created {
t.Fatalf("expected no-op without config, got created=%v err=%v", created, err)
}
if len(store.accounts) != 0 {
t.Fatalf("expected no accounts, got %d", len(store.accounts))
}
}
func TestBootstrapSuperAdminNoopOnLocalBackend(t *testing.T) {
store := newBootstrapFakeStore()
service := administration.NewService(store)
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendLocal, service, configuredBootstrap())
if err != nil || created {
t.Fatalf("expected no-op on local backend, got created=%v err=%v", created, err)
}
if len(store.accounts) != 0 {
t.Fatalf("expected no accounts, got %d", len(store.accounts))
}
}
func TestBootstrapSuperAdminCreatesOnce(t *testing.T) {
store := newBootstrapFakeStore()
service := administration.NewService(store)
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, configuredBootstrap())
if err != nil || !created {
t.Fatalf("expected first bootstrap to create, got created=%v err=%v", created, err)
}
if len(store.accounts) != 1 {
t.Fatalf("expected exactly one account, got %d", len(store.accounts))
}
for _, account := range store.accounts {
if account.Role != administration.RoleSuperAdmin || account.Status != administration.StatusActive {
t.Fatalf("unexpected bootstrapped account: %+v", account)
}
if account.PasswordHash == "change-me-now" || account.PasswordHash == "" {
t.Fatalf("password must be hashed, got %q", account.PasswordHash)
}
}
created, err = BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, configuredBootstrap())
if err != nil || created {
t.Fatalf("expected second bootstrap to skip, got created=%v err=%v", created, err)
}
if len(store.accounts) != 1 {
t.Fatalf("expected still exactly one account, got %d", len(store.accounts))
}
}
func TestBootstrapSuperAdminSkipsDisabledSuperAdmin(t *testing.T) {
store := newBootstrapFakeStore()
existing := administration.Account{
ID: "user-existing", Phone: "13900139000", DisplayName: "已有超管",
Role: administration.RoleSuperAdmin, Status: administration.StatusDisabled,
SessionVersion: 1,
}
store.accounts[existing.ID] = existing
service := administration.NewService(store)
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, configuredBootstrap())
if err != nil || created {
t.Fatalf("expected skip with existing disabled super admin, got created=%v err=%v", created, err)
}
if len(store.accounts) != 1 {
t.Fatalf("expected no new account, got %d", len(store.accounts))
}
}
func TestBootstrapSuperAdminRejectsShortPassword(t *testing.T) {
store := newBootstrapFakeStore()
service := administration.NewService(store)
config := configuredBootstrap()
config.Password = "short"
created, err := BootstrapSuperAdmin(context.Background(), postgres.BackendPostgres, service, config)
if err == nil {
t.Fatalf("expected short password to fail")
}
if created {
t.Fatalf("expected no account on failed bootstrap")
}
if len(store.accounts) != 0 {
t.Fatalf("expected no accounts, got %d", len(store.accounts))
}
}