diff --git a/server/Dockerfile b/server/Dockerfile index e5cd043..13db063 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -26,6 +26,7 @@ RUN apk --no-cache add ca-certificates COPY --from=migrate-tool /usr/local/bin/migrate /usr/local/bin/migrate COPY migrations/ /migrations/ COPY migrations_monitoring/ /migrations_monitoring/ +COPY migrations_ops/ /migrations_ops/ ENTRYPOINT ["/usr/local/bin/migrate"] # ─── Stage 3: Runtime image ──────────────────────────────────────────────────── diff --git a/server/Makefile b/server/Makefile index fe9e043..503719b 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,7 +1,10 @@ -.PHONY: dev-init dev-api dev-worker-generate dev-scheduler migrate-up migrate-down migrate-monitoring-up migrate-monitoring-down migrate-create sqlc-generate tenant-scope-guard lint test tidy seed +.PHONY: dev-init dev-api dev-worker-generate dev-scheduler dev-ops-api migrate-up migrate-down migrate-monitoring-up migrate-monitoring-down migrate-ops-up migrate-ops-down migrate-create migrate-ops-create sqlc-generate tenant-scope-guard lint test tidy seed DB_URL ?= postgres://geo:geo_dev@localhost:5432/geo?sslmode=disable MONITORING_DB_URL ?= postgres://geo:geo_dev@localhost:5433/geo_monitoring?sslmode=disable +# ops 迁移与主迁移共用 geo 数据库,必须使用独立 schema_migrations_ops 表, +# 否则两套迁移会互相看到对方的版本号并报 "no migration found"。 +OPS_DB_URL ?= postgres://geo:geo_dev@localhost:5432/geo?sslmode=disable&x-migrations-table=schema_migrations_ops MIGRATE_CMD ?= go run -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1 dev-init: ## Start infra, run migrations, seed data @@ -10,6 +13,7 @@ dev-init: ## Start infra, run migrations, seed data @sleep 3 $(MAKE) migrate-up $(MAKE) migrate-monitoring-up + $(MAKE) migrate-ops-up $(MAKE) seed dev-api: ## Run tenant-api server @@ -21,6 +25,9 @@ dev-worker-generate: ## Run article generation worker dev-scheduler: ## Run scheduler workers go run ./cmd/scheduler +dev-ops-api: ## Run ops-api server (internal operations console backend) + CONFIG_PATH=configs/ops-config.yaml go run ./cmd/ops-api + migrate-up: ## Run all pending migrations $(MIGRATE_CMD) -path migrations -database "$(DB_URL)" up @@ -33,9 +40,18 @@ migrate-monitoring-up: ## Run all pending monitoring migrations migrate-monitoring-down: ## Rollback last monitoring migration $(MIGRATE_CMD) -path migrations_monitoring -database "$(MONITORING_DB_URL)" down 1 +migrate-ops-up: ## Run all pending ops migrations (operator accounts, audit logs) + $(MIGRATE_CMD) -path migrations_ops -database "$(OPS_DB_URL)" up + +migrate-ops-down: ## Rollback last ops migration + $(MIGRATE_CMD) -path migrations_ops -database "$(OPS_DB_URL)" down 1 + migrate-create: ## Create new migration (usage: make migrate-create NAME=xxx) $(MIGRATE_CMD) create -ext sql -dir migrations -seq $(NAME) +migrate-ops-create: ## Create new ops migration (usage: make migrate-ops-create NAME=xxx) + $(MIGRATE_CMD) create -ext sql -dir migrations_ops -seq $(NAME) + sqlc-generate: ## Generate typed Go code from SQL queries cd internal/tenant/repository && sqlc generate diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go new file mode 100644 index 0000000..c7c2d2a --- /dev/null +++ b/server/cmd/ops-api/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + + opsconfig "github.com/geo-platform/tenant-api/internal/ops/config" + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/ops/repository" + "github.com/geo-platform/tenant-api/internal/ops/transport" + "github.com/geo-platform/tenant-api/internal/shared/observability" + "github.com/geo-platform/tenant-api/internal/shared/repository/postgres" +) + +func main() { + configPath := "configs/ops-config.yaml" + if p := os.Getenv("CONFIG_PATH"); p != "" { + configPath = p + } + + cfg, err := opsconfig.Load(configPath) + if err != nil { + log.Fatalf("ops-api load config: %v", err) + } + + logger, err := observability.NewLogger(cfg.Log.Level, cfg.Log.Format) + if err != nil { + log.Fatalf("ops-api init logger: %v", err) + } + defer func() { _ = logger.Sync() }() + + ctx := context.Background() + pool, err := postgres.NewPool(ctx, cfg.Database) + if err != nil { + logger.Sugar().Fatalf("ops-api init postgres: %v", err) + } + defer pool.Close() + + accountsRepo := repository.NewAccountRepository(pool) + auditsRepo := repository.NewAuditRepository(pool) + + auditSvc := app.NewAuditService(auditsRepo, logger) + issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL) + authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer) + accountSvc := app.NewAccountService(accountsRepo, auditSvc) + + if err := app.SeedDefaultAdmin(ctx, accountsRepo, app.DefaultAdminSeed{ + Username: cfg.DefaultAdmin.Username, + Password: cfg.DefaultAdmin.Password, + DisplayName: cfg.DefaultAdmin.DisplayName, + Email: cfg.DefaultAdmin.Email, + }, logger); err != nil { + logger.Sugar().Fatalf("ops-api seed default admin: %v", err) + } + + if cfg.Server.Mode == "release" { + gin.SetMode(gin.ReleaseMode) + } + engine := gin.New() + + transport.RegisterRoutes(transport.Deps{ + Engine: engine, + Logger: logger, + DB: pool, + Issuer: issuer, + Auth: authSvc, + Accounts: accountSvc, + Audits: auditSvc, + }) + + addr := fmt.Sprintf(":%d", cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: engine, + ReadHeaderTimeout: 10 * time.Second, + } + + logger.Sugar().Infof("ops-api starting on %s", addr) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Sugar().Fatalf("ops-api server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("ops-api shutting down...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + logger.Sugar().Warnf("ops-api shutdown: %v", err) + } +} diff --git a/server/configs/ops-config.yaml b/server/configs/ops-config.yaml new file mode 100644 index 0000000..4880855 --- /dev/null +++ b/server/configs/ops-config.yaml @@ -0,0 +1,27 @@ +server: + port: 8090 + mode: debug + +database: + host: localhost + port: 5432 + user: geo + password: geo_dev + dbname: geo + sslmode: disable + max_open_conns: 10 + max_idle_conns: 2 + +log: + level: info + format: json + +jwt: + secret: "15645415841" # 必须通过 OPS_JWT_SECRET 环境变量提供 + access_ttl: 8h + +default_admin: + username: admin + display_name: 系统管理员 + email: "" + password: "liang0920" # 必须通过 OPS_DEFAULT_ADMIN_PASSWORD 提供(首次启动且表为空时使用) diff --git a/server/internal/ops/app/account.go b/server/internal/ops/app/account.go new file mode 100644 index 0000000..affe33d --- /dev/null +++ b/server/internal/ops/app/account.go @@ -0,0 +1,275 @@ +package app + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "golang.org/x/crypto/bcrypt" + + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/ops/repository" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +const ( + ActionAccountCreate = "account.create" + ActionAccountDisable = "account.disable" + ActionAccountEnable = "account.enable" + ActionAccountResetPassword = "account.reset_password" + ActionAccountUpdateProfile = "account.update_profile" + ActionAccountChangePassword = "account.change_password" +) + +type AccountService struct { + accounts *repository.AccountRepository + audits *AuditService +} + +func NewAccountService(accounts *repository.AccountRepository, audits *AuditService) *AccountService { + return &AccountService{accounts: accounts, audits: audits} +} + +type CreateInput struct { + Username string + Email string + Password string + DisplayName string + Role string +} + +type ListInput struct { + Keyword string + Status string + Page int + Size int +} + +type ListResult struct { + Items []OperatorView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +type OperatorDetail struct { + OperatorView + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toDetail(a *domain.OperatorAccount) OperatorDetail { + return OperatorDetail{ + OperatorView: ToOperatorView(a), + LastLoginAt: a.LastLoginAt, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +func (s *AccountService) List(ctx context.Context, in ListInput) (*ListResult, error) { + page := in.Page + if page <= 0 { + page = 1 + } + size := in.Size + if size <= 0 || size > 200 { + size = 20 + } + + items, total, err := s.accounts.List(ctx, repository.ListFilter{ + Keyword: strings.TrimSpace(in.Keyword), + Status: strings.TrimSpace(in.Status), + Limit: size, + Offset: (page - 1) * size, + }) + if err != nil { + return nil, err + } + + views := make([]OperatorView, 0, len(items)) + for i := range items { + views = append(views, ToOperatorView(&items[i])) + } + return &ListResult{Items: views, Total: total, Page: page, Size: size}, nil +} + +func (s *AccountService) Get(ctx context.Context, id int64) (*OperatorDetail, error) { + account, err := s.accounts.GetByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrAccountNotFound) { + return nil, response.ErrNotFound(40410, "account_not_found", "账号不存在") + } + return nil, err + } + d := toDetail(account) + return &d, nil +} + +func (s *AccountService) Create(ctx context.Context, actor *Actor, in CreateInput) (*OperatorDetail, error) { + username := strings.TrimSpace(in.Username) + if username == "" { + return nil, response.ErrBadRequest(40010, "invalid_username", "用户名不能为空") + } + if len(in.Password) < 8 { + return nil, response.ErrBadRequest(40011, "weak_password", "密码至少 8 位") + } + displayName := strings.TrimSpace(in.DisplayName) + if displayName == "" { + displayName = username + } + role := strings.TrimSpace(in.Role) + if role == "" { + role = domain.RoleAdmin + } + + hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + var emailPtr *string + if email := strings.TrimSpace(in.Email); email != "" { + emailPtr = &email + } + + var creatorID *int64 + if actor != nil { + id := actor.OperatorID + creatorID = &id + } + + account, err := s.accounts.Create(ctx, repository.CreateAccountInput{ + Username: username, + Email: emailPtr, + PasswordHash: string(hash), + DisplayName: displayName, + Role: role, + CreatedBy: creatorID, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, response.ErrConflict(40910, "duplicate_account", "用户名或邮箱已存在") + } + return nil, err + } + + if actor != nil { + _ = s.audits.Append(ctx, actor.audit(ActionAccountCreate, "operator_account", account.ID, map[string]any{ + "username": account.Username, + "display_name": account.DisplayName, + "role": account.Role, + })) + } + + d := toDetail(account) + return &d, nil +} + +func (s *AccountService) SetStatus(ctx context.Context, actor *Actor, id int64, status string) error { + if status != domain.StatusActive && status != domain.StatusDisabled { + return response.ErrBadRequest(40020, "invalid_status", "状态值无效") + } + if actor != nil && actor.OperatorID == id && status == domain.StatusDisabled { + return response.ErrBadRequest(40021, "cannot_disable_self", "不能停用自己的账号") + } + + if err := s.accounts.UpdateStatus(ctx, id, status); err != nil { + if errors.Is(err, repository.ErrAccountNotFound) { + return response.ErrNotFound(40410, "account_not_found", "账号不存在") + } + return err + } + + action := ActionAccountEnable + if status == domain.StatusDisabled { + action = ActionAccountDisable + } + if actor != nil { + _ = s.audits.Append(ctx, actor.audit(action, "operator_account", id, nil)) + } + return nil +} + +func (s *AccountService) ResetPassword(ctx context.Context, actor *Actor, id int64, newPassword string) error { + if len(newPassword) < 8 { + return response.ErrBadRequest(40011, "weak_password", "密码至少 8 位") + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + if err := s.accounts.UpdatePassword(ctx, id, string(hash)); err != nil { + if errors.Is(err, repository.ErrAccountNotFound) { + return response.ErrNotFound(40410, "account_not_found", "账号不存在") + } + return err + } + if actor != nil { + _ = s.audits.Append(ctx, actor.audit(ActionAccountResetPassword, "operator_account", id, nil)) + } + return nil +} + +type UpdateProfileInput struct { + DisplayName string + Email string +} + +func (s *AccountService) UpdateProfile(ctx context.Context, actor *Actor, id int64, in UpdateProfileInput) error { + displayName := strings.TrimSpace(in.DisplayName) + if displayName == "" { + return response.ErrBadRequest(40012, "invalid_display_name", "显示名称不能为空") + } + var emailPtr *string + if email := strings.TrimSpace(in.Email); email != "" { + emailPtr = &email + } + if err := s.accounts.UpdateProfile(ctx, id, displayName, emailPtr); err != nil { + if errors.Is(err, repository.ErrAccountNotFound) { + return response.ErrNotFound(40410, "account_not_found", "账号不存在") + } + return err + } + if actor != nil { + _ = s.audits.Append(ctx, actor.audit(ActionAccountUpdateProfile, "operator_account", id, map[string]any{ + "display_name": displayName, + "email": emailPtr, + })) + } + return nil +} + +type ChangeOwnPasswordInput struct { + OldPassword string + NewPassword string +} + +func (s *AccountService) ChangeOwnPassword(ctx context.Context, actor *Actor, in ChangeOwnPasswordInput) error { + if actor == nil { + return response.ErrUnauthorized(40101, "missing_actor", "未登录") + } + if len(in.NewPassword) < 8 { + return response.ErrBadRequest(40011, "weak_password", "新密码至少 8 位") + } + account, err := s.accounts.GetByID(ctx, actor.OperatorID) + if err != nil { + return response.ErrNotFound(40410, "account_not_found", "账号不存在") + } + if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.OldPassword)); err != nil { + return response.ErrUnauthorized(40111, "invalid_old_password", "原密码错误") + } + hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + if err := s.accounts.UpdatePassword(ctx, actor.OperatorID, string(hash)); err != nil { + return err + } + _ = s.audits.Append(ctx, actor.audit(ActionAccountChangePassword, "operator_account", actor.OperatorID, nil)) + return nil +} diff --git a/server/internal/ops/app/actor.go b/server/internal/ops/app/actor.go new file mode 100644 index 0000000..c39c412 --- /dev/null +++ b/server/internal/ops/app/actor.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + + "github.com/geo-platform/tenant-api/internal/ops/domain" +) + +type Actor struct { + OperatorID int64 + Username string + DisplayName string + Role string + IP string + UserAgent string + RequestID string +} + +func (a *Actor) audit(action, targetType string, targetID int64, metadata map[string]any) domain.AuditEvent { + id := a.OperatorID + var tt, ti string + tt = targetType + if targetID != 0 { + ti = formatInt64(targetID) + } + return domain.AuditEvent{ + OperatorID: &id, + OperatorName: a.DisplayName, + Action: action, + TargetType: tt, + TargetID: ti, + Metadata: metadata, + IP: a.IP, + UserAgent: a.UserAgent, + RequestID: a.RequestID, + } +} + +type actorContextKey struct{} + +func WithActor(ctx context.Context, actor *Actor) context.Context { + return context.WithValue(ctx, actorContextKey{}, actor) +} + +func ActorFromContext(ctx context.Context) *Actor { + if actor, ok := ctx.Value(actorContextKey{}).(*Actor); ok { + return actor + } + return nil +} + +func formatInt64(n int64) string { + if n == 0 { + return "" + } + const digits = "0123456789" + if n < 0 { + return "-" + formatInt64(-n) + } + buf := make([]byte, 0, 20) + for n > 0 { + buf = append([]byte{digits[n%10]}, buf...) + n /= 10 + } + return string(buf) +} diff --git a/server/internal/ops/app/audit.go b/server/internal/ops/app/audit.go new file mode 100644 index 0000000..2c4c9ac --- /dev/null +++ b/server/internal/ops/app/audit.go @@ -0,0 +1,34 @@ +package app + +import ( + "context" + + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/ops/repository" +) + +type AuditService struct { + repo *repository.AuditRepository + logger *zap.Logger +} + +func NewAuditService(repo *repository.AuditRepository, logger *zap.Logger) *AuditService { + return &AuditService{repo: repo, logger: logger} +} + +func (s *AuditService) Append(ctx context.Context, e domain.AuditEvent) error { + if err := s.repo.Append(ctx, e); err != nil { + s.logger.Warn("ops audit append failed", + zap.String("action", e.Action), + zap.Error(err), + ) + return err + } + return nil +} + +func (s *AuditService) List(ctx context.Context, f domain.AuditLogFilter) ([]domain.AuditLog, int64, error) { + return s.repo.List(ctx, f) +} diff --git a/server/internal/ops/app/auth.go b/server/internal/ops/app/auth.go new file mode 100644 index 0000000..3b1fe64 --- /dev/null +++ b/server/internal/ops/app/auth.go @@ -0,0 +1,188 @@ +package app + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/ops/repository" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type OperatorClaims struct { + OperatorID int64 `json:"operator_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type TokenIssuer struct { + secret []byte + accessTTL time.Duration +} + +func NewTokenIssuer(secret string, accessTTL time.Duration) *TokenIssuer { + return &TokenIssuer{secret: []byte(secret), accessTTL: accessTTL} +} + +func (t *TokenIssuer) AccessTTL() time.Duration { return t.accessTTL } + +type IssuedToken struct { + AccessToken string + ExpiresAt int64 +} + +func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, error) { + now := time.Now() + exp := now.Add(t.accessTTL) + claims := OperatorClaims{ + OperatorID: operator.ID, + Username: operator.Username, + DisplayName: operator.DisplayName, + Role: operator.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ID: uuid.New().String(), + Subject: "ops-access", + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(exp), + }, + } + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret) + if err != nil { + return nil, fmt.Errorf("sign ops token: %w", err) + } + return &IssuedToken{AccessToken: signed, ExpiresAt: exp.Unix()}, nil +} + +func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) { + token, err := jwt.ParseWithClaims(raw, &OperatorClaims{}, func(tok *jwt.Token) (any, error) { + if _, ok := tok.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", tok.Header["alg"]) + } + return t.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*OperatorClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid ops token") + } + if claims.Subject != "ops-access" { + return nil, errors.New("wrong token subject") + } + return claims, nil +} + +type AuthService struct { + accounts *repository.AccountRepository + audits *AuditService + issuer *TokenIssuer +} + +func NewAuthService(accounts *repository.AccountRepository, audits *AuditService, issuer *TokenIssuer) *AuthService { + return &AuthService{accounts: accounts, audits: audits, issuer: issuer} +} + +type LoginInput struct { + Username string + Password string + IP string + UserAgent string + RequestID string +} + +type LoginResult struct { + AccessToken string + ExpiresAt int64 + Operator OperatorView +} + +type OperatorView struct { + ID int64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email *string `json:"email"` + Role string `json:"role"` + Status string `json:"status"` +} + +func ToOperatorView(a *domain.OperatorAccount) OperatorView { + return OperatorView{ + ID: a.ID, + Username: a.Username, + DisplayName: a.DisplayName, + Email: a.Email, + Role: a.Role, + Status: a.Status, + } +} + +const ( + ActionLoginSuccess = "auth.login.success" + ActionLoginFailed = "auth.login.failure" +) + +func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, error) { + account, err := s.accounts.GetByUsername(ctx, in.Username) + if err != nil { + s.recordLoginFailure(ctx, in, "account_not_found") + return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误") + } + + if !account.IsActive() { + s.recordLoginFailure(ctx, in, "account_disabled") + return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用") + } + + if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil { + s.recordLoginFailure(ctx, in, "wrong_password") + return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误") + } + + token, err := s.issuer.Issue(account) + if err != nil { + return nil, fmt.Errorf("issue token: %w", err) + } + + if err := s.accounts.MarkLogin(ctx, account.ID); err != nil { + // non-fatal — login already succeeded + _ = err + } + + id := account.ID + _ = s.audits.Append(ctx, domain.AuditEvent{ + OperatorID: &id, + OperatorName: account.DisplayName, + Action: ActionLoginSuccess, + IP: in.IP, + UserAgent: in.UserAgent, + RequestID: in.RequestID, + }) + + return &LoginResult{ + AccessToken: token.AccessToken, + ExpiresAt: token.ExpiresAt, + Operator: ToOperatorView(account), + }, nil +} + +func (s *AuthService) recordLoginFailure(ctx context.Context, in LoginInput, reason string) { + _ = s.audits.Append(ctx, domain.AuditEvent{ + Action: ActionLoginFailed, + IP: in.IP, + UserAgent: in.UserAgent, + RequestID: in.RequestID, + Metadata: map[string]any{ + "username": in.Username, + "reason": reason, + }, + }) +} diff --git a/server/internal/ops/app/seed.go b/server/internal/ops/app/seed.go new file mode 100644 index 0000000..ec87385 --- /dev/null +++ b/server/internal/ops/app/seed.go @@ -0,0 +1,69 @@ +package app + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/ops/repository" +) + +type DefaultAdminSeed struct { + Username string + Password string + DisplayName string + Email string +} + +// SeedDefaultAdmin inserts a single admin account when the operator_accounts +// table is empty. It is safe to call on every startup — a non-empty table is +// a no-op. Returns an error only when the table is empty AND the seed config +// is incomplete (so a fresh deployment cannot silently boot without any +// way to log in). +func SeedDefaultAdmin(ctx context.Context, accounts *repository.AccountRepository, seed DefaultAdminSeed, logger *zap.Logger) error { + count, err := accounts.Count(ctx) + if err != nil { + return fmt.Errorf("count operator accounts: %w", err) + } + if count > 0 { + return nil + } + + username := strings.TrimSpace(seed.Username) + password := seed.Password + if username == "" || password == "" { + return errors.New("operator_accounts is empty but default_admin.username/password are not configured (set OPS_DEFAULT_ADMIN_USERNAME / OPS_DEFAULT_ADMIN_PASSWORD)") + } + displayName := strings.TrimSpace(seed.DisplayName) + if displayName == "" { + displayName = username + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash default admin password: %w", err) + } + + var emailPtr *string + if email := strings.TrimSpace(seed.Email); email != "" { + emailPtr = &email + } + + _, err = accounts.Create(ctx, repository.CreateAccountInput{ + Username: username, + Email: emailPtr, + PasswordHash: string(hash), + DisplayName: displayName, + Role: domain.RoleAdmin, + }) + if err != nil { + return fmt.Errorf("create default admin: %w", err) + } + logger.Sugar().Infof("ops default admin seeded: username=%s", username) + return nil +} diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go new file mode 100644 index 0000000..f11e309 --- /dev/null +++ b/server/internal/ops/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/viper" + + sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" +) + +type Config struct { + Server sharedconfig.ServerConfig `mapstructure:"server"` + Database sharedconfig.DatabaseConfig `mapstructure:"database"` + Log sharedconfig.LogConfig `mapstructure:"log"` + JWT JWTConfig `mapstructure:"jwt"` + DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` +} + +type JWTConfig struct { + Secret string `mapstructure:"secret"` + AccessTTL time.Duration `mapstructure:"access_ttl"` +} + +type DefaultAdminConfig struct { + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + DisplayName string `mapstructure:"display_name"` + Email string `mapstructure:"email"` +} + +func Load(path string) (*Config, error) { + v := viper.New() + v.SetConfigFile(path) + + v.SetEnvPrefix("OPS") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + v.SetDefault("server.port", 8090) + v.SetDefault("server.mode", "debug") + v.SetDefault("log.level", "info") + v.SetDefault("log.format", "json") + v.SetDefault("jwt.access_ttl", 8*time.Hour) + v.SetDefault("default_admin.username", "admin") + v.SetDefault("default_admin.display_name", "Administrator") + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + if err := applyEnvOverrides(&cfg); err != nil { + return nil, err + } + + if cfg.JWT.Secret == "" { + return nil, fmt.Errorf("jwt.secret is required (set jwt.secret in yaml or OPS_JWT_SECRET env)") + } + + return &cfg, nil +} + +func applyEnvOverrides(cfg *Config) error { + if v := os.Getenv("OPS_JWT_SECRET"); v != "" { + cfg.JWT.Secret = v + } + if v := os.Getenv("OPS_DEFAULT_ADMIN_USERNAME"); v != "" { + cfg.DefaultAdmin.Username = v + } + if v := os.Getenv("OPS_DEFAULT_ADMIN_PASSWORD"); v != "" { + cfg.DefaultAdmin.Password = v + } + if v := os.Getenv("OPS_DEFAULT_ADMIN_DISPLAY_NAME"); v != "" { + cfg.DefaultAdmin.DisplayName = v + } + if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" { + cfg.DefaultAdmin.Email = v + } + return nil +} diff --git a/server/internal/ops/domain/types.go b/server/internal/ops/domain/types.go new file mode 100644 index 0000000..6123d98 --- /dev/null +++ b/server/internal/ops/domain/types.go @@ -0,0 +1,63 @@ +package domain + +import "time" + +const ( + StatusActive = "active" + StatusDisabled = "disabled" + + RoleAdmin = "admin" +) + +type OperatorAccount struct { + ID int64 + Username string + Email *string + PasswordHash string + DisplayName string + Role string + Status string + LastLoginAt *time.Time + CreatedBy *int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (a OperatorAccount) IsActive() bool { + return a.Status == StatusActive +} + +type AuditEvent struct { + OperatorID *int64 + OperatorName string + Action string + TargetType string + TargetID string + Metadata map[string]any + IP string + UserAgent string + RequestID string +} + +type AuditLog struct { + ID int64 + OperatorID *int64 + OperatorName *string + Action string + TargetType *string + TargetID *string + Metadata map[string]any + IP *string + UserAgent *string + RequestID *string + CreatedAt time.Time +} + +type AuditLogFilter struct { + OperatorID *int64 + Action string + StartAt *time.Time + EndAt *time.Time + Limit int + Offset int +} diff --git a/server/internal/ops/repository/account.go b/server/internal/ops/repository/account.go new file mode 100644 index 0000000..f232b3f --- /dev/null +++ b/server/internal/ops/repository/account.go @@ -0,0 +1,194 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/geo-platform/tenant-api/internal/ops/domain" +) + +var ErrAccountNotFound = errors.New("operator account not found") + +type AccountRepository struct { + pool *pgxpool.Pool +} + +func NewAccountRepository(pool *pgxpool.Pool) *AccountRepository { + return &AccountRepository{pool: pool} +} + +const accountColumns = `id, username, email, password_hash, display_name, role, status, + last_login_at, created_by, created_at, updated_at` + +func scanAccount(row pgx.Row) (*domain.OperatorAccount, error) { + var a domain.OperatorAccount + err := row.Scan( + &a.ID, &a.Username, &a.Email, &a.PasswordHash, &a.DisplayName, + &a.Role, &a.Status, &a.LastLoginAt, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAccountNotFound + } + return nil, err + } + return &a, nil +} + +func (r *AccountRepository) GetByUsername(ctx context.Context, username string) (*domain.OperatorAccount, error) { + row := r.pool.QueryRow(ctx, ` + SELECT `+accountColumns+` + FROM ops.operator_accounts + WHERE username = $1 AND deleted_at IS NULL`, username) + return scanAccount(row) +} + +func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*domain.OperatorAccount, error) { + row := r.pool.QueryRow(ctx, ` + SELECT `+accountColumns+` + FROM ops.operator_accounts + WHERE id = $1 AND deleted_at IS NULL`, id) + return scanAccount(row) +} + +func (r *AccountRepository) Count(ctx context.Context) (int64, error) { + var n int64 + err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM ops.operator_accounts WHERE deleted_at IS NULL`).Scan(&n) + return n, err +} + +type ListFilter struct { + Keyword string + Status string + Limit int + Offset int +} + +func (r *AccountRepository) List(ctx context.Context, f ListFilter) ([]domain.OperatorAccount, int64, error) { + args := []any{} + where := "deleted_at IS NULL" + + if f.Keyword != "" { + args = append(args, "%"+f.Keyword+"%") + where += fmt.Sprintf(" AND (username ILIKE $%d OR display_name ILIKE $%d OR email ILIKE $%d)", + len(args), len(args), len(args)) + } + if f.Status != "" { + args = append(args, f.Status) + where += fmt.Sprintf(" AND status = $%d", len(args)) + } + + var total int64 + if err := r.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ops.operator_accounts WHERE "+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + + limit := f.Limit + if limit <= 0 || limit > 200 { + limit = 20 + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + limitArg := len(args) - 1 + offsetArg := len(args) + + rows, err := r.pool.Query(ctx, fmt.Sprintf(` + SELECT %s + FROM ops.operator_accounts + WHERE %s + ORDER BY created_at DESC + LIMIT $%d OFFSET $%d`, accountColumns, where, limitArg, offsetArg), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + out := make([]domain.OperatorAccount, 0, limit) + for rows.Next() { + var a domain.OperatorAccount + if err := rows.Scan( + &a.ID, &a.Username, &a.Email, &a.PasswordHash, &a.DisplayName, + &a.Role, &a.Status, &a.LastLoginAt, &a.CreatedBy, &a.CreatedAt, &a.UpdatedAt, + ); err != nil { + return nil, 0, err + } + out = append(out, a) + } + return out, total, rows.Err() +} + +type CreateAccountInput struct { + Username string + Email *string + PasswordHash string + DisplayName string + Role string + CreatedBy *int64 +} + +func (r *AccountRepository) Create(ctx context.Context, in CreateAccountInput) (*domain.OperatorAccount, error) { + row := r.pool.QueryRow(ctx, ` + INSERT INTO ops.operator_accounts (username, email, password_hash, display_name, role, status, created_by) + VALUES ($1, $2, $3, $4, $5, 'active', $6) + RETURNING `+accountColumns, + in.Username, in.Email, in.PasswordHash, in.DisplayName, in.Role, in.CreatedBy) + return scanAccount(row) +} + +func (r *AccountRepository) UpdateStatus(ctx context.Context, id int64, status string) error { + cmd, err := r.pool.Exec(ctx, ` + UPDATE ops.operator_accounts + SET status = $1, updated_at = NOW() + WHERE id = $2 AND deleted_at IS NULL`, status, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrAccountNotFound + } + return nil +} + +func (r *AccountRepository) UpdatePassword(ctx context.Context, id int64, passwordHash string) error { + cmd, err := r.pool.Exec(ctx, ` + UPDATE ops.operator_accounts + SET password_hash = $1, updated_at = NOW() + WHERE id = $2 AND deleted_at IS NULL`, passwordHash, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrAccountNotFound + } + return nil +} + +func (r *AccountRepository) UpdateProfile(ctx context.Context, id int64, displayName string, email *string) error { + cmd, err := r.pool.Exec(ctx, ` + UPDATE ops.operator_accounts + SET display_name = $1, email = $2, updated_at = NOW() + WHERE id = $3 AND deleted_at IS NULL`, displayName, email, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrAccountNotFound + } + return nil +} + +func (r *AccountRepository) MarkLogin(ctx context.Context, id int64) error { + _, err := r.pool.Exec(ctx, ` + UPDATE ops.operator_accounts + SET last_login_at = NOW(), updated_at = NOW() + WHERE id = $1`, id) + return err +} diff --git a/server/internal/ops/repository/audit.go b/server/internal/ops/repository/audit.go new file mode 100644 index 0000000..c717e49 --- /dev/null +++ b/server/internal/ops/repository/audit.go @@ -0,0 +1,127 @@ +package repository + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/geo-platform/tenant-api/internal/ops/domain" +) + +type AuditRepository struct { + pool *pgxpool.Pool +} + +func NewAuditRepository(pool *pgxpool.Pool) *AuditRepository { + return &AuditRepository{pool: pool} +} + +func (r *AuditRepository) Append(ctx context.Context, e domain.AuditEvent) error { + var metaBytes []byte + if e.Metadata != nil { + b, err := json.Marshal(e.Metadata) + if err != nil { + return fmt.Errorf("marshal audit metadata: %w", err) + } + metaBytes = b + } + + var ( + operatorName = nullableString(e.OperatorName) + targetType = nullableString(e.TargetType) + targetID = nullableString(e.TargetID) + ip = nullableString(e.IP) + ua = nullableString(e.UserAgent) + reqID = nullableString(e.RequestID) + ) + + _, err := r.pool.Exec(ctx, ` + INSERT INTO ops.audit_logs + (operator_id, operator_name, action, target_type, target_id, metadata, ip, user_agent, request_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + e.OperatorID, operatorName, e.Action, targetType, targetID, metaBytes, ip, ua, reqID) + return err +} + +func (r *AuditRepository) List(ctx context.Context, f domain.AuditLogFilter) ([]domain.AuditLog, int64, error) { + args := []any{} + where := "1=1" + + if f.OperatorID != nil { + args = append(args, *f.OperatorID) + where += fmt.Sprintf(" AND operator_id = $%d", len(args)) + } + if f.Action != "" { + args = append(args, f.Action) + where += fmt.Sprintf(" AND action = $%d", len(args)) + } + if f.StartAt != nil { + args = append(args, *f.StartAt) + where += fmt.Sprintf(" AND created_at >= $%d", len(args)) + } + if f.EndAt != nil { + args = append(args, *f.EndAt) + where += fmt.Sprintf(" AND created_at < $%d", len(args)) + } + + var total int64 + if err := r.pool.QueryRow(ctx, + "SELECT COUNT(*) FROM ops.audit_logs WHERE "+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + + limit := f.Limit + if limit <= 0 || limit > 500 { + limit = 50 + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + limitArg := len(args) - 1 + offsetArg := len(args) + + rows, err := r.pool.Query(ctx, fmt.Sprintf(` + SELECT id, operator_id, operator_name, action, target_type, target_id, + metadata, ip, user_agent, request_id, created_at + FROM ops.audit_logs + WHERE %s + ORDER BY created_at DESC, id DESC + LIMIT $%d OFFSET $%d`, where, limitArg, offsetArg), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + out := make([]domain.AuditLog, 0, limit) + for rows.Next() { + var ( + log domain.AuditLog + metaRaw []byte + ) + if err := rows.Scan( + &log.ID, &log.OperatorID, &log.OperatorName, &log.Action, + &log.TargetType, &log.TargetID, &metaRaw, &log.IP, &log.UserAgent, + &log.RequestID, &log.CreatedAt, + ); err != nil { + return nil, 0, err + } + if len(metaRaw) > 0 { + if err := json.Unmarshal(metaRaw, &log.Metadata); err != nil { + return nil, 0, fmt.Errorf("decode audit metadata: %w", err) + } + } + out = append(out, log) + } + return out, total, rows.Err() +} + +func nullableString(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/server/internal/ops/transport/account_handler.go b/server/internal/ops/transport/account_handler.go new file mode 100644 index 0000000..ed8038b --- /dev/null +++ b/server/internal/ops/transport/account_handler.go @@ -0,0 +1,183 @@ +package transport + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type createAccountRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Role string `json:"role"` +} + +type updateProfileRequest struct { + DisplayName string `json:"display_name" binding:"required"` + Email string `json:"email"` +} + +type setStatusRequest struct { + Status string `json:"status" binding:"required"` +} + +type resetPasswordRequest struct { + Password string `json:"password" binding:"required"` +} + +type changeOwnPasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required"` +} + +func listAccountsHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + result, err := svc.List(c.Request.Context(), app.ListInput{ + Keyword: c.Query("keyword"), + Status: c.Query("status"), + Page: page, + Size: size, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func createAccountHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + var body createAccountRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + actor := actorFromGin(c) + detail, err := svc.Create(c.Request.Context(), actor, app.CreateInput{ + Username: body.Username, + Password: body.Password, + DisplayName: body.DisplayName, + Email: body.Email, + Role: body.Role, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func getAccountHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + detail, err := svc.Get(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func updateAccountProfileHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body updateProfileRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.UpdateProfile(c.Request.Context(), actorFromGin(c), id, app.UpdateProfileInput{ + DisplayName: body.DisplayName, + Email: body.Email, + }); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id}) + } +} + +func setAccountStatusHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body setStatusRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.SetStatus(c.Request.Context(), actorFromGin(c), id, body.Status); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id, "status": body.Status}) + } +} + +func resetAccountPasswordHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body resetPasswordRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.ResetPassword(c.Request.Context(), actorFromGin(c), id, body.Password); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id}) + } +} + +func changeOwnPasswordHandler(svc *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + var body changeOwnPasswordRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.ChangeOwnPassword(c.Request.Context(), actorFromGin(c), app.ChangeOwnPasswordInput{ + OldPassword: body.OldPassword, + NewPassword: body.NewPassword, + }); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"ok": true}) + } +} + +func parseIDParam(c *gin.Context) (int64, error) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + return 0, response.ErrBadRequest(40001, "invalid_id", "无效的 ID") + } + return id, nil +} diff --git a/server/internal/ops/transport/audit_handler.go b/server/internal/ops/transport/audit_handler.go new file mode 100644 index 0000000..b12a9f1 --- /dev/null +++ b/server/internal/ops/transport/audit_handler.go @@ -0,0 +1,104 @@ +package transport + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +func listAuditsHandler(svc *app.AuditService) gin.HandlerFunc { + return func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "50")) + if page <= 0 { + page = 1 + } + if size <= 0 || size > 500 { + size = 50 + } + + filter := domain.AuditLogFilter{ + Action: c.Query("action"), + Limit: size, + Offset: (page - 1) * size, + } + + if v := c.Query("operator_id"); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40002, "invalid_operator_id", "operator_id 不合法")) + return + } + filter.OperatorID = &id + } + if v := c.Query("start_at"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + response.Error(c, response.ErrBadRequest(40003, "invalid_start_at", "start_at 必须是 RFC3339")) + return + } + filter.StartAt = &t + } + if v := c.Query("end_at"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + response.Error(c, response.ErrBadRequest(40004, "invalid_end_at", "end_at 必须是 RFC3339")) + return + } + filter.EndAt = &t + } + + items, total, err := svc.List(c.Request.Context(), filter) + if err != nil { + response.Error(c, err) + return + } + + dtos := make([]auditLogDTO, 0, len(items)) + for _, log := range items { + dtos = append(dtos, toAuditLogDTO(log)) + } + + response.Success(c, gin.H{ + "items": dtos, + "total": total, + "page": page, + "size": size, + }) + } +} + +type auditLogDTO struct { + ID int64 `json:"id"` + OperatorID *int64 `json:"operator_id"` + OperatorName *string `json:"operator_name"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Metadata map[string]any `json:"metadata"` + IP *string `json:"ip"` + UserAgent *string `json:"user_agent"` + RequestID *string `json:"request_id"` + CreatedAt time.Time `json:"created_at"` +} + +func toAuditLogDTO(l domain.AuditLog) auditLogDTO { + return auditLogDTO{ + ID: l.ID, + OperatorID: l.OperatorID, + OperatorName: l.OperatorName, + Action: l.Action, + TargetType: l.TargetType, + TargetID: l.TargetID, + Metadata: l.Metadata, + IP: l.IP, + UserAgent: l.UserAgent, + RequestID: l.RequestID, + CreatedAt: l.CreatedAt, + } +} diff --git a/server/internal/ops/transport/auth_handler.go b/server/internal/ops/transport/auth_handler.go new file mode 100644 index 0000000..776b323 --- /dev/null +++ b/server/internal/ops/transport/auth_handler.go @@ -0,0 +1,64 @@ +package transport + +import ( + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type loginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type loginResponse struct { + AccessToken string `json:"access_token"` + ExpiresAt int64 `json:"expires_at"` + Operator app.OperatorView `json:"operator"` +} + +func loginHandler(svc *app.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + var body loginRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + + result, err := svc.Login(c.Request.Context(), app.LoginInput{ + Username: body.Username, + Password: body.Password, + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + RequestID: middleware.RequestIDFromGin(c), + }) + if err != nil { + response.Error(c, err) + return + } + + response.Success(c, loginResponse{ + AccessToken: result.AccessToken, + ExpiresAt: result.ExpiresAt, + Operator: result.Operator, + }) + } +} + +func meHandler(accounts *app.AccountService) gin.HandlerFunc { + return func(c *gin.Context) { + actor := actorFromGin(c) + if actor == nil { + response.Error(c, response.ErrUnauthorized(40101, "missing_actor", "未登录")) + return + } + detail, err := accounts.Get(c.Request.Context(), actor.OperatorID) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} diff --git a/server/internal/ops/transport/middleware.go b/server/internal/ops/transport/middleware.go new file mode 100644 index 0000000..aaf8327 --- /dev/null +++ b/server/internal/ops/transport/middleware.go @@ -0,0 +1,63 @@ +package transport + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +func authMiddleware(issuer *app.TokenIssuer) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + raw, ok := bearerToken(header) + if !ok { + response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "缺少认证 token")) + c.Abort() + return + } + + claims, err := issuer.Parse(raw) + if err != nil { + response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token 无效或已过期")) + c.Abort() + return + } + + actor := &app.Actor{ + OperatorID: claims.OperatorID, + Username: claims.Username, + DisplayName: claims.DisplayName, + Role: claims.Role, + IP: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + RequestID: middleware.RequestIDFromGin(c), + } + c.Set(actorGinKey, actor) + c.Request = c.Request.WithContext(app.WithActor(c.Request.Context(), actor)) + c.Next() + } +} + +const actorGinKey = "ops_actor" + +func actorFromGin(c *gin.Context) *app.Actor { + if v, ok := c.Get(actorGinKey); ok { + if actor, ok := v.(*app.Actor); ok { + return actor + } + } + return nil +} + +func bearerToken(header string) (string, bool) { + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "", false + } + token := strings.TrimSpace(parts[1]) + return token, token != "" +} diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go new file mode 100644 index 0000000..5ffdd28 --- /dev/null +++ b/server/internal/ops/transport/router.go @@ -0,0 +1,62 @@ +package transport + +import ( + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + "go.uber.org/zap" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/middleware" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type Deps struct { + Engine *gin.Engine + Logger *zap.Logger + DB *pgxpool.Pool + Issuer *app.TokenIssuer + Auth *app.AuthService + Accounts *app.AccountService + Audits *app.AuditService +} + +func RegisterRoutes(d Deps) { + d.Engine.Use( + middleware.Recovery(d.Logger), + middleware.RequestID(), + middleware.Logger(d.Logger), + middleware.CORS(), + ) + + d.Engine.GET("/api/health/live", func(c *gin.Context) { + response.Success(c, gin.H{"status": "alive"}) + }) + d.Engine.GET("/api/health/ready", func(c *gin.Context) { + if err := d.DB.Ping(c.Request.Context()); err != nil { + response.Error(c, response.ErrServiceUnavailable(50301, "postgres_unavailable", "数据库不可达")) + return + } + response.Success(c, gin.H{"status": "ready"}) + }) + + api := d.Engine.Group("/api/ops") + { + api.POST("/auth/login", loginHandler(d.Auth)) + } + + authed := api.Group("") + authed.Use(authMiddleware(d.Issuer)) + { + authed.GET("/auth/me", meHandler(d.Accounts)) + authed.POST("/auth/password", changeOwnPasswordHandler(d.Accounts)) + + authed.GET("/accounts", listAccountsHandler(d.Accounts)) + authed.POST("/accounts", createAccountHandler(d.Accounts)) + authed.GET("/accounts/:id", getAccountHandler(d.Accounts)) + authed.PATCH("/accounts/:id", updateAccountProfileHandler(d.Accounts)) + authed.POST("/accounts/:id/status", setAccountStatusHandler(d.Accounts)) + authed.POST("/accounts/:id/password", resetAccountPasswordHandler(d.Accounts)) + + authed.GET("/audits", listAuditsHandler(d.Audits)) + } +} diff --git a/server/migrations_ops/20260428100000_create_ops_schema.down.sql b/server/migrations_ops/20260428100000_create_ops_schema.down.sql new file mode 100644 index 0000000..8cbd67e --- /dev/null +++ b/server/migrations_ops/20260428100000_create_ops_schema.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS ops.audit_logs; +DROP TABLE IF EXISTS ops.operator_accounts; +DROP SCHEMA IF EXISTS ops; diff --git a/server/migrations_ops/20260428100000_create_ops_schema.up.sql b/server/migrations_ops/20260428100000_create_ops_schema.up.sql new file mode 100644 index 0000000..71c06e0 --- /dev/null +++ b/server/migrations_ops/20260428100000_create_ops_schema.up.sql @@ -0,0 +1,48 @@ +CREATE SCHEMA IF NOT EXISTS ops; + +CREATE TABLE ops.operator_accounts ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL, + email VARCHAR(255), + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100) NOT NULL, + role VARCHAR(32) NOT NULL DEFAULT 'admin', + status VARCHAR(20) NOT NULL DEFAULT 'active', + last_login_at TIMESTAMPTZ, + created_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX uk_ops_operator_username + ON ops.operator_accounts(username) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX uk_ops_operator_email + ON ops.operator_accounts(email) + WHERE email IS NOT NULL AND deleted_at IS NULL; + +CREATE TABLE ops.audit_logs ( + id BIGSERIAL PRIMARY KEY, + operator_id BIGINT, + operator_name VARCHAR(100), + action VARCHAR(64) NOT NULL, + target_type VARCHAR(64), + target_id VARCHAR(128), + metadata JSONB, + ip VARCHAR(64), + user_agent TEXT, + request_id VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_ops_audit_created_at + ON ops.audit_logs(created_at DESC); + +CREATE INDEX idx_ops_audit_operator + ON ops.audit_logs(operator_id, created_at DESC) + WHERE operator_id IS NOT NULL; + +CREATE INDEX idx_ops_audit_action + ON ops.audit_logs(action, created_at DESC);