feat: add administration domain core

This commit is contained in:
2026-08-13 16:32:07 +08:00
parent 065dcd955a
commit cea2751dc5
9 changed files with 1135 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
)
const accountColumns = `id, phone, display_name, role, organization_id, status, password_hash, password_salt, failed_login_count, locked_until, session_version, last_login_at, legacy_subject, created_at, updated_at`
const organizationColumns = `id, name, status, archive_owner_id, created_at, updated_at`
const ListAdministrationAccountsSQL = `SELECT ` + accountColumns + ` FROM public.platform_users WHERE ($1::text = '' OR organization_id = $1::text) AND ($2::text = '' OR role = $2::text) AND ($3::boolean OR status = 'active') ORDER BY created_at DESC`
const GetAdministrationAccountSQL = `SELECT ` + accountColumns + ` FROM public.platform_users WHERE id = $1::text`
const CreateAdministrationAccountSQL = `INSERT INTO public.platform_users (` + accountColumns + `) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING ` + accountColumns
const UpdateAdministrationAccountSQL = `UPDATE public.platform_users SET phone=$2, display_name=$3, role=$4, organization_id=$5, status=$6, password_hash=$7, password_salt=$8, failed_login_count=$9, locked_until=$10, session_version=$11, last_login_at=$12, legacy_subject=$13, created_at=$14, updated_at=$15 WHERE id=$1::text RETURNING ` + accountColumns
const ListAdministrationOrganizationsSQL = `SELECT ` + organizationColumns + ` FROM public.platform_organizations WHERE ($1::boolean OR status = 'active') ORDER BY created_at ASC`
const GetAdministrationOrganizationSQL = `SELECT ` + organizationColumns + ` FROM public.platform_organizations WHERE id = $1::text`
const CreateAdministrationOrganizationSQL = `INSERT INTO public.platform_organizations (` + organizationColumns + `) VALUES ($1,$2,$3,$4,$5,$6) RETURNING ` + organizationColumns
const UpdateAdministrationOrganizationSQL = `UPDATE public.platform_organizations SET name=$2, status=$3, archive_owner_id=$4, created_at=$5, updated_at=$6 WHERE id=$1::text RETURNING ` + organizationColumns
const CountAdministrationOrganizationMembersSQL = `SELECT count(*) FROM public.platform_users WHERE organization_id = $1::text`
const DeleteAdministrationOrganizationSQL = `DELETE FROM public.platform_organizations WHERE id = $1::text RETURNING id`
const ArchiveAssetsSQL = `UPDATE public.assets SET owner_id = $2::text WHERE owner_id = $1::text`
const ArchiveGenerationJobsSQL = `UPDATE public.generation_jobs SET owner_id = $2::text WHERE owner_id = $1::text`
const ArchiveProjectsSQL = `UPDATE public.projects SET owner_id = $2::text WHERE owner_id = $1::text`
const ArchiveImageTemplatesSQL = `UPDATE public.image_templates SET owner_id = $2::text WHERE owner_id = $1::text`
const DeleteAdministrationAccountSQL = `DELETE FROM public.platform_users WHERE id = $1::text`
func (db *Database) administrationQuerier() (Querier, error) {
if db.config.Backend != BackendPostgres || db.querier == nil {
return nil, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
}
return db.querier, nil
}
func (db *Database) ListAccounts(ctx context.Context, f administration.AccountFilters) ([]administration.Account, error) {
q, e := db.administrationQuerier()
if e != nil {
return nil, e
}
rows, e := q.Query(ctx, ListAdministrationAccountsSQL, f.OrganizationID, f.Role, f.IncludeDisabled)
if e != nil {
return nil, fmt.Errorf("list administration accounts: %w", e)
}
defer rows.Close()
var out []administration.Account
for rows.Next() {
a, e := scanAdministrationAccount(rows)
if e != nil {
return nil, e
}
out = append(out, a)
}
return out, rows.Err()
}
func (db *Database) GetAccount(ctx context.Context, id string) (administration.Account, bool, error) {
q, e := db.administrationQuerier()
if e != nil {
return administration.Account{}, false, e
}
rows, e := q.Query(ctx, GetAdministrationAccountSQL, id)
if e != nil {
return administration.Account{}, false, fmt.Errorf("get administration account: %w", e)
}
defer rows.Close()
if !rows.Next() {
return administration.Account{}, false, rows.Err()
}
a, e := scanAdministrationAccount(rows)
return a, e == nil, e
}
func (db *Database) CreateAccount(ctx context.Context, a administration.Account) (administration.Account, error) {
return db.writeAccount(ctx, CreateAdministrationAccountSQL, a)
}
func (db *Database) UpdateAccount(ctx context.Context, a administration.Account) (administration.Account, error) {
return db.writeAccount(ctx, UpdateAdministrationAccountSQL, a)
}
func (db *Database) writeAccount(ctx context.Context, statement string, a administration.Account) (administration.Account, error) {
q, e := db.administrationQuerier()
if e != nil {
return administration.Account{}, e
}
rows, e := q.Query(ctx, statement, accountArgs(a)...)
if e != nil {
return administration.Account{}, administrationWriteError(e)
}
defer rows.Close()
if !rows.Next() {
return administration.Account{}, &administration.Error{Kind: administration.ErrorNotFound, Message: "账号不存在。"}
}
return scanAdministrationAccount(rows)
}
func accountArgs(a administration.Account) []any {
return []any{a.ID, a.Phone, a.DisplayName, a.Role, optionalDatabaseText(a.OrganizationID), a.Status, a.PasswordHash, a.PasswordSalt, a.FailedLoginCount, a.LockedUntil, a.SessionVersion, a.LastLoginAt, optionalDatabaseText(a.LegacySubject), a.CreatedAt, a.UpdatedAt}
}
func scanAdministrationAccount(rows Rows) (administration.Account, error) {
var a administration.Account
var org, legacy sql.NullString
var locked, last sql.NullTime
if e := rows.Scan(&a.ID, &a.Phone, &a.DisplayName, &a.Role, &org, &a.Status, &a.PasswordHash, &a.PasswordSalt, &a.FailedLoginCount, &locked, &a.SessionVersion, &last, &legacy, &a.CreatedAt, &a.UpdatedAt); e != nil {
return a, fmt.Errorf("scan administration account: %w", e)
}
if org.Valid {
a.OrganizationID = org.String
}
if legacy.Valid {
a.LegacySubject = legacy.String
}
if locked.Valid {
a.LockedUntil = &locked.Time
}
if last.Valid {
a.LastLoginAt = &last.Time
}
return a, nil
}
func (db *Database) ListOrganizations(ctx context.Context, include bool) ([]administration.Organization, error) {
q, e := db.administrationQuerier()
if e != nil {
return nil, e
}
rows, e := q.Query(ctx, ListAdministrationOrganizationsSQL, include)
if e != nil {
return nil, fmt.Errorf("list administration organizations: %w", e)
}
defer rows.Close()
var out []administration.Organization
for rows.Next() {
o, e := scanAdministrationOrganization(rows)
if e != nil {
return nil, e
}
out = append(out, o)
}
return out, rows.Err()
}
func (db *Database) GetOrganization(ctx context.Context, id string) (administration.Organization, bool, error) {
q, e := db.administrationQuerier()
if e != nil {
return administration.Organization{}, false, e
}
rows, e := q.Query(ctx, GetAdministrationOrganizationSQL, id)
if e != nil {
return administration.Organization{}, false, e
}
defer rows.Close()
if !rows.Next() {
return administration.Organization{}, false, rows.Err()
}
o, e := scanAdministrationOrganization(rows)
return o, e == nil, e
}
func (db *Database) CreateOrganization(ctx context.Context, o administration.Organization) (administration.Organization, error) {
return db.writeOrganization(ctx, CreateAdministrationOrganizationSQL, o)
}
func (db *Database) UpdateOrganization(ctx context.Context, o administration.Organization) (administration.Organization, error) {
return db.writeOrganization(ctx, UpdateAdministrationOrganizationSQL, o)
}
func (db *Database) writeOrganization(ctx context.Context, statement string, o administration.Organization) (administration.Organization, error) {
q, e := db.administrationQuerier()
if e != nil {
return administration.Organization{}, e
}
rows, e := q.Query(ctx, statement, o.ID, o.Name, o.Status, o.ArchiveOwnerID, o.CreatedAt, o.UpdatedAt)
if e != nil {
return administration.Organization{}, administrationWriteError(e)
}
defer rows.Close()
if !rows.Next() {
return administration.Organization{}, &administration.Error{Kind: administration.ErrorNotFound, Message: "组织不存在。"}
}
return scanAdministrationOrganization(rows)
}
func scanAdministrationOrganization(rows Rows) (administration.Organization, error) {
var o administration.Organization
if e := rows.Scan(&o.ID, &o.Name, &o.Status, &o.ArchiveOwnerID, &o.CreatedAt, &o.UpdatedAt); e != nil {
return o, fmt.Errorf("scan administration organization: %w", e)
}
return o, nil
}
func (db *Database) CountOrganizationMembers(ctx context.Context, id string) (int, error) {
q, e := db.administrationQuerier()
if e != nil {
return 0, e
}
rows, e := q.Query(ctx, CountAdministrationOrganizationMembersSQL, id)
if e != nil {
return 0, e
}
defer rows.Close()
if !rows.Next() {
return 0, fmt.Errorf("member count returned no row")
}
var count int
e = rows.Scan(&count)
return count, e
}
func (db *Database) DeleteOrganization(ctx context.Context, id string) error {
q, e := db.administrationQuerier()
if e != nil {
return e
}
rows, e := q.Query(ctx, DeleteAdministrationOrganizationSQL, id)
if e != nil {
return administrationWriteError(e)
}
defer rows.Close()
if !rows.Next() {
if e := rows.Err(); e != nil {
return e
}
return &administration.Error{Kind: administration.ErrorNotFound, Message: "组织不存在。"}
}
var deletedID string
if e := rows.Scan(&deletedID); e != nil {
return fmt.Errorf("scan deleted organization: %w", e)
}
return rows.Err()
}
func (db *Database) DeleteAccount(ctx context.Context, id, archive string) error {
if db.config.Backend != BackendPostgres || db.transactions == nil {
return fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
}
tx, e := db.transactions.Begin(ctx)
if e != nil {
return fmt.Errorf("begin delete account transaction: %w", e)
}
done := false
defer func() {
if !done {
_ = tx.Rollback(ctx)
}
}()
for _, statement := range []string{ArchiveAssetsSQL, ArchiveGenerationJobsSQL, ArchiveProjectsSQL, ArchiveImageTemplatesSQL} {
if e = tx.Exec(ctx, statement, id, archive); e != nil {
return fmt.Errorf("archive account ownership: %w", e)
}
}
if e = tx.Exec(ctx, DeleteAdministrationAccountSQL, id); e != nil {
return fmt.Errorf("delete administration account: %w", e)
}
if e = tx.Commit(ctx); e != nil {
return fmt.Errorf("commit delete account transaction: %w", e)
}
done = true
return nil
}
func administrationWriteError(err error) error {
if sqlState(err) == "23505" {
return &administration.Error{Kind: administration.ErrorConflict, Message: "唯一字段已存在。", Err: err}
}
return err
}

View File

@@ -0,0 +1,96 @@
package postgres
import (
"context"
"errors"
"reflect"
"testing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
)
func TestAdministrationListAccountsUsesExplicitColumnsAndParameterizedFilters(t *testing.T) {
rows := &identityRows{}
querier := &identityQuerier{rows: rows}
db := NewDatabase(Config{Backend: BackendPostgres}, querier)
_, err := db.ListAccounts(context.Background(), administration.AccountFilters{OrganizationID: "org-1", Role: administration.RoleUser, IncludeDisabled: true})
if err != nil {
t.Fatal(err)
}
if querier.sql != ListAdministrationAccountsSQL {
t.Fatalf("SQL=%q", querier.sql)
}
if !reflect.DeepEqual(querier.args, []any{"org-1", administration.RoleUser, true}) {
t.Fatalf("args=%#v", querier.args)
}
}
func TestDeleteAccountArchivesAllOwnedDataAndIdentityInOneTransaction(t *testing.T) {
tx := &administrationTransaction{}
db := NewDatabase(Config{Backend: BackendPostgres}, &administrationPool{tx: tx})
if err := db.DeleteAccount(context.Background(), "user-1", "archive:org-1"); err != nil {
t.Fatal(err)
}
wantSQL := []string{ArchiveAssetsSQL, ArchiveGenerationJobsSQL, ArchiveProjectsSQL, ArchiveImageTemplatesSQL, DeleteAdministrationAccountSQL}
if !reflect.DeepEqual(tx.sql, wantSQL) {
t.Fatalf("SQL sequence=%#v want %#v", tx.sql, wantSQL)
}
for _, args := range tx.args {
if !reflect.DeepEqual(args, []any{"user-1", "archive:org-1"}) && !reflect.DeepEqual(args, []any{"user-1"}) {
t.Fatalf("args=%#v", args)
}
}
if tx.commits != 1 || tx.rollbacks != 0 {
t.Fatalf("commits=%d rollbacks=%d", tx.commits, tx.rollbacks)
}
}
func TestDeleteAccountRollsBackArchiveTransactionFailure(t *testing.T) {
tx := &administrationTransaction{failAt: 3, err: errors.New("projects failed")}
db := NewDatabase(Config{Backend: BackendPostgres}, &administrationPool{tx: tx})
err := db.DeleteAccount(context.Background(), "user-1", "archive:org-1")
if !errors.Is(err, tx.err) || tx.commits != 0 || tx.rollbacks != 1 {
t.Fatalf("err=%v commits=%d rollbacks=%d", err, tx.commits, tx.rollbacks)
}
}
func TestAdministrationStoreFailsClosedWithoutPostgres(t *testing.T) {
db := NewDatabase(Config{Backend: BackendLocal}, &identityQuerier{})
if _, err := db.ListOrganizations(context.Background(), true); err == nil {
t.Fatal("ListOrganizations error=nil")
}
if err := db.DeleteAccount(context.Background(), "u", "a"); err == nil {
t.Fatal("DeleteAccount error=nil")
}
}
var _ administration.Store = (*Database)(nil)
type administrationPool struct{ tx *administrationTransaction }
func (p *administrationPool) Query(context.Context, string, ...any) (Rows, error) {
return nil, errors.New("outside transaction")
}
func (p *administrationPool) Begin(context.Context) (Transaction, error) { return p.tx, nil }
type administrationTransaction struct {
sql []string
args [][]any
failAt int
err error
commits, rollbacks int
}
func (t *administrationTransaction) Query(context.Context, string, ...any) (Rows, error) {
return nil, errors.New("unexpected query")
}
func (t *administrationTransaction) Exec(_ context.Context, query string, args ...any) error {
t.sql = append(t.sql, query)
t.args = append(t.args, args)
if t.failAt == len(t.sql) {
return t.err
}
return nil
}
func (t *administrationTransaction) Commit(context.Context) error { t.commits++; return nil }
func (t *administrationTransaction) Rollback(context.Context) error { t.rollbacks++; return nil }