diff --git a/.project-docs/30-worklog/tasks/20260814-go-bootstrap-admin-6e2b7d9c.md b/.project-docs/30-worklog/tasks/20260814-go-bootstrap-admin-6e2b7d9c.md new file mode 100644 index 0000000..8afad98 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260814-go-bootstrap-admin-6e2b7d9c.md @@ -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. diff --git a/backend/README.md b/backend/README.md index b4ecd18..b2010bf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go index 6b4df49..38a1666 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -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 diff --git a/backend/internal/application/bootstrap_admin.go b/backend/internal/application/bootstrap_admin.go new file mode 100644 index 0000000..36be710 --- /dev/null +++ b/backend/internal/application/bootstrap_admin.go @@ -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 +} diff --git a/backend/internal/application/bootstrap_admin_test.go b/backend/internal/application/bootstrap_admin_test.go new file mode 100644 index 0000000..c9baebe --- /dev/null +++ b/backend/internal/application/bootstrap_admin_test.go @@ -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)) + } +}