feat(server/ops-api): add operations console backend
Stand up an internal ops-api service with operator account, auth, and audit log subsystems backed by a dedicated migrations_ops migration set. Wire Makefile targets and the migrate Dockerfile stage so the new schema ships alongside the existing tenant + monitoring databases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 提供(首次启动且表为空时使用)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS ops.audit_logs;
|
||||
DROP TABLE IF EXISTS ops.operator_accounts;
|
||||
DROP SCHEMA IF EXISTS ops;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user