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:
2026-04-28 11:32:50 +08:00
parent 299e7c311e
commit c091ad7aed
20 changed files with 1778 additions and 1 deletions
+1
View File
@@ -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
View File
@@ -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
+103
View File
@@ -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)
}
}
+27
View File
@@ -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 提供(首次启动且表为空时使用)
+275
View File
@@ -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
}
+66
View File
@@ -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)
}
+34
View File
@@ -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)
}
+188
View File
@@ -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,
},
})
}
+69
View File
@@ -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
}
+87
View File
@@ -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
}
+63
View File
@@ -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
}
+194
View File
@@ -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
}
+127
View File
@@ -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 != ""
}
+62
View File
@@ -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);