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
+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
}