feat(frontend): add Helm chart browser, monitoring, chart-references and values templates pages

Add new frontend pages for the multi-tenant OCDP platform:

- Charts page (/charts): Browse Harbor OCI registries to list Helm chart repositories
  and versions, with deploy modal to launch charts on selected clusters
- Monitoring page (/monitoring): Display cluster metrics (CPU/Memory/GPU usage)
  and per-node details with resource utilization bars
- Chart References page (/chart-references): CRUD for chart metadata references
- Values Templates page (/templates): CRUD for Helm values templates with version
  history and rollback support
- Sidebar: Add Charts navigation, update Storage and Templates links
- api.ts: Add all API client functions (clusterApi, registryApi, instanceApi,
  monitoringApi, storageApi, chartReferenceApi, valuesTemplateApi,
  workspaceApi, userApi) with full TypeScript types

Note: deploy flow and values template rollback not yet end-to-end tested.
This commit is contained in:
Ivan087
2026-04-15 16:59:31 +08:00
parent c5e51ed069
commit 29d0310f03
283 changed files with 24658 additions and 36038 deletions

View File

@ -68,6 +68,16 @@ func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repos
return s.ociClient.GetValuesSchema(ctx, registry, repository, reference)
}
// GetValues 获取 Helm Chart 的 values.yaml
func (s *ArtifactService) GetValues(ctx context.Context, registryID, repository, reference string) (string, error) {
registry, err := s.registryRepo.GetByID(ctx, registryID)
if err != nil {
return "", entity.ErrRegistryNotFound
}
return s.ociClient.GetValues(ctx, registry, repository, reference)
}
// PullArtifact 下载 artifact
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
registry, err := s.registryRepo.GetByID(ctx, registryID)

View File

@ -0,0 +1,71 @@
package service
import (
"context"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// AuditService 审计日志领域服务
type AuditService struct {
auditLogRepo repository.AuditLogRepository
}
// NewAuditService 创建审计服务
func NewAuditService(auditLogRepo repository.AuditLogRepository) *AuditService {
return &AuditService{
auditLogRepo: auditLogRepo,
}
}
// Log 创建审计日志
func (s *AuditService) Log(ctx context.Context, workspaceID, userID string, action entity.AuditAction, resourceType entity.AuditResourceType, resourceID, resourceName string, details map[string]interface{}, ipAddress, userAgent string) error {
auditLog := &entity.AuditLog{
ID: uuid.New().String(),
WorkspaceID: workspaceID,
UserID: userID,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
ResourceName: resourceName,
Details: details,
IPAddress: ipAddress,
UserAgent: userAgent,
CreatedAt: time.Now(),
}
return s.auditLogRepo.Create(ctx, auditLog)
}
// LogAction 简化版日志记录
func (s *AuditService) LogAction(ctx context.Context, workspaceID, userID string, action entity.AuditAction, resourceType entity.AuditResourceType, resourceName string) error {
return s.Log(ctx, workspaceID, userID, action, resourceType, "", resourceName, nil, "", "")
}
// LogWithDetails 带详情的日志记录
func (s *AuditService) LogWithDetails(ctx context.Context, workspaceID, userID string, action entity.AuditAction, resourceType entity.AuditResourceType, resourceID, resourceName string, details map[string]interface{}) error {
return s.Log(ctx, workspaceID, userID, action, resourceType, resourceID, resourceName, details, "", "")
}
// GetLogs 获取审计日志
func (s *AuditService) GetLogs(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.GetByWorkspace(ctx, workspaceID, limit)
}
// GetUserLogs 获取用户的审计日志
func (s *AuditService) GetUserLogs(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.GetByUser(ctx, userID, limit)
}
// GetResourceLogs 获取资源的审计日志
func (s *AuditService) GetResourceLogs(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.GetByResource(ctx, resourceType, resourceID, limit)
}
// GetAllLogs 获取所有审计日志Admin
func (s *AuditService) GetAllLogs(ctx context.Context, limit int, offset int) ([]*entity.AuditLog, error) {
return s.auditLogRepo.List(ctx, limit, offset)
}

View File

@ -22,9 +22,9 @@ type PasswordHasher interface {
// TokenGenerator Token 生成器接口
type TokenGenerator interface {
Generate(userID, username string) (accessToken, refreshToken string, err error)
Verify(token string) (userID, username string, err error)
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error)
Verify(token string) (userID, username, role, workspaceID string, err error)
VerifyWithIssuedAt(token string) (userID, username, role, workspaceID string, issuedAt int64, err error)
Refresh(refreshToken string) (newAccessToken string, err error)
}
@ -86,8 +86,8 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (acc
return "", "", entity.ErrInvalidPassword
}
// 生成 Token
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username)
// 生成 Token (包含 role 和 workspace_id)
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username, string(user.Role), user.WorkspaceID)
if err != nil {
return "", "", err
}
@ -108,7 +108,7 @@ func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User,
// VerifyAccessToken 验证 Access Token包括 revoked_after 检查)
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
// 1. JWT 自验证
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
userID, username, _, _, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
if err != nil {
return "", "", err
}

View File

@ -0,0 +1,137 @@
package service
import (
"context"
"errors"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
var (
ErrChartReferenceNotFound = errors.New("chart reference not found")
ErrChartReferenceExists = errors.New("chart reference already exists")
)
// ChartReferenceService Chart 引用领域服务
type ChartReferenceService struct {
chartRefRepo repository.ChartReferenceRepository
registryRepo repository.RegistryRepository
}
// NewChartReferenceService 创建 Chart 引用服务
func NewChartReferenceService(
chartRefRepo repository.ChartReferenceRepository,
registryRepo repository.RegistryRepository,
) *ChartReferenceService {
return &ChartReferenceService{
chartRefRepo: chartRefRepo,
registryRepo: registryRepo,
}
}
// Create 创建 Chart 引用
func (s *ChartReferenceService) Create(
ctx context.Context,
workspaceID, registryID, repository, chartName, description string,
) (*entity.ChartReference, error) {
// 检查 Registry 是否存在
registry, err := s.registryRepo.GetByID(ctx, registryID)
if err != nil {
return nil, errors.New("registry not found")
}
// 检查名称是否已存在
existing, _ := s.chartRefRepo.GetByName(ctx, workspaceID, chartName)
if existing != nil {
return nil, ErrChartReferenceExists
}
chartRef := entity.NewChartReference(workspaceID, registry.ID, repository, chartName, description)
chartRef.Description = description
if err := s.chartRefRepo.Create(ctx, chartRef); err != nil {
return nil, err
}
return chartRef, nil
}
// GetByID 获取 Chart 引用
func (s *ChartReferenceService) GetByID(ctx context.Context, id string) (*entity.ChartReference, error) {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrChartReferenceNotFound
}
return chartRef, nil
}
// GetByWorkspace 获取工作空间的所有 Chart 引用
func (s *ChartReferenceService) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error) {
return s.chartRefRepo.GetByWorkspace(ctx, workspaceID)
}
// GetByRegistry 获取 Registry 的所有 Chart 引用
func (s *ChartReferenceService) GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error) {
return s.chartRefRepo.GetByRegistry(ctx, registryID)
}
// Update 更新 Chart 引用
func (s *ChartReferenceService) Update(
ctx context.Context,
id, registryID, repository, chartName, description string,
isEnabled bool,
) (*entity.ChartReference, error) {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrChartReferenceNotFound
}
if registryID != "" {
chartRef.RegistryID = registryID
}
if repository != "" {
chartRef.Repository = repository
}
if chartName != "" {
chartRef.ChartName = chartName
}
chartRef.Description = description
chartRef.IsEnabled = isEnabled
if err := s.chartRefRepo.Update(ctx, chartRef); err != nil {
return nil, err
}
return chartRef, nil
}
// Delete 删除 Chart 引用
func (s *ChartReferenceService) Delete(ctx context.Context, id string) error {
return s.chartRefRepo.Delete(ctx, id)
}
// List 列出所有 Chart 引用(管理员用)
func (s *ChartReferenceService) List(ctx context.Context) ([]*entity.ChartReference, error) {
return s.chartRefRepo.List(ctx)
}
// Enable 启用 Chart 引用
func (s *ChartReferenceService) Enable(ctx context.Context, id string) error {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return ErrChartReferenceNotFound
}
chartRef.IsEnabled = true
return s.chartRefRepo.Update(ctx, chartRef)
}
// Disable 禁用 Chart 引用
func (s *ChartReferenceService) Disable(ctx context.Context, id string) error {
chartRef, err := s.chartRefRepo.GetByID(ctx, id)
if err != nil {
return ErrChartReferenceNotFound
}
chartRef.IsEnabled = false
return s.chartRefRepo.Update(ctx, chartRef)
}

View File

@ -2,9 +2,16 @@ package service
import (
"context"
"encoding/base64"
"fmt"
"os"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// ClusterService 集群管理领域服务
@ -75,3 +82,105 @@ func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, e
return s.clusterRepo.List(ctx)
}
// ListByWorkspace 列出指定 workspace 的集群(包括共享集群)
func (s *ClusterService) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
return s.clusterRepo.GetByWorkspace(ctx, workspaceID)
}
// GetSharedClusters 获取所有共享集群
func (s *ClusterService) GetSharedClusters(ctx context.Context) ([]*entity.Cluster, error) {
return s.clusterRepo.GetShared(ctx)
}
// TestConnection 测试集群连接是否可用
func (s *ClusterService) TestConnection(ctx context.Context, cluster *entity.Cluster) error {
// Mock 模式直接返回成功
if os.Getenv("ADAPTER_MODE") == "mock" {
return nil
}
// 尝试创建 k8s client
config, err := s.createRestConfig(cluster)
if err != nil {
return fmt.Errorf("failed to create k8s config: %w", err)
}
// 设置超时
config.Timeout = 30 * 1000000000 // 30秒 (nanoseconds)
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return fmt.Errorf("failed to create k8s client: %w", err)
}
// 测试连接 - 获取 version 信息
version, err := clientset.ServerVersion()
if err != nil {
return fmt.Errorf("failed to connect to cluster: %w", err)
}
if version == nil {
return fmt.Errorf("cluster returned nil version")
}
return nil
}
// createRestConfig 从 cluster 实体创建 k8s REST 配置
func (s *ClusterService) createRestConfig(cluster *entity.Cluster) (*rest.Config, error) {
// 优先使用 kubeconfig 格式(如果 CAData 包含完整的 kubeconfig 内容)
if len(cluster.CAData) > 100 && (cluster.CAData[:11] == "apiVersion:" || cluster.CAData[:5] == "kind:") {
return clientcmd.RESTConfigFromKubeConfig([]byte(cluster.CAData))
}
// 使用证书或 token 认证
config := &rest.Config{
Host: cluster.Host,
}
if cluster.CertData != "" && cluster.KeyData != "" {
// 尝试解码 base64 编码的证书,如果失败则尝试原始 PEM
var caData, certData, keyData []byte
var decodeErr error
// 先尝试 base64 解码
caData, decodeErr = base64.StdEncoding.DecodeString(cluster.CAData)
if decodeErr != nil {
// base64 解码失败,可能是原始 PEM
caData = []byte(cluster.CAData)
}
certData, decodeErr = base64.StdEncoding.DecodeString(cluster.CertData)
if decodeErr != nil {
certData = []byte(cluster.CertData)
}
keyData, decodeErr = base64.StdEncoding.DecodeString(cluster.KeyData)
if decodeErr != nil {
keyData = []byte(cluster.KeyData)
}
config.TLSClientConfig = rest.TLSClientConfig{
CAData: caData,
CertData: certData,
KeyData: keyData,
Insecure: false,
}
} else if cluster.Token != "" {
config.BearerToken = cluster.Token
} else {
// 尝试使用本地 kubeconfig
kubeconfig := os.Getenv("KUBECONFIG")
if kubeconfig == "" {
kubeconfig = ".kube/config"
}
// 尝试从文件加载 kubeconfig
if _, err := os.Stat(kubeconfig); err == nil {
return clientcmd.BuildConfigFromFlags("", kubeconfig)
}
return clientcmd.BuildConfigFromFlags("", kubeconfig)
}
return config, nil
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
@ -336,9 +337,17 @@ func (s *InstanceService) executeAndSyncRollback(ctx context.Context, instanceID
// executeAndSyncUninstall 异步执行卸载并监控状态
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
// 先验证 release 名称是否有效
// 如果名称无效,说明这个 release 根本不可能存在于 Helm 中,直接删除数据库记录
if err := entity.ValidateReleaseName(releaseName); err != nil {
// Release 名称无效,直接删除数据库记录
_ = s.instanceRepo.Delete(ctx, instanceID)
return
}
// 执行 Helm 卸载
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
// 获取实例
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
if getErr != nil {
@ -346,13 +355,22 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
}
if err != nil {
// 如果错误不是"未找到",则标记为失败
if !errors.Is(err, entity.ErrInstanceNotFound) {
instance.MarkFailure("Helm uninstall failed", err)
_ = s.instanceRepo.Update(ctx, instance)
} else {
// 如果未找到,说明已经卸载,直接删除数据库记录
// 检查错误类型
if errors.Is(err, entity.ErrInstanceNotFound) {
// 未找到,说明已经卸载,直接删除数据库记录
_ = s.instanceRepo.Delete(ctx, instanceID)
} else {
// 检查是否是 release 名称无效的错误(可能在某些情况下 Helm 会返回这个错误)
errMsg := strings.ToLower(err.Error())
if strings.Contains(errMsg, "release name is invalid") ||
(strings.Contains(errMsg, "invalid") && strings.Contains(errMsg, "release")) {
// Release 名称无效,直接删除数据库记录
_ = s.instanceRepo.Delete(ctx, instanceID)
} else {
// 其他错误,标记为失败
instance.MarkFailure("Helm uninstall failed", err)
_ = s.instanceRepo.Update(ctx, instance)
}
}
return
}
@ -360,7 +378,7 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
// 卸载成功,标记为已卸载
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
_ = s.instanceRepo.Update(ctx, instance)
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
time.Sleep(3 * time.Second)
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)

View File

@ -0,0 +1,224 @@
package service
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// QuotaService 配额领域服务
type QuotaService struct {
quotaRepo repository.QuotaRepository
instanceRepo repository.InstanceRepository
workspaceRepo repository.WorkspaceRepository
}
// NewQuotaService 创建配额服务
func NewQuotaService(
quotaRepo repository.QuotaRepository,
instanceRepo repository.InstanceRepository,
workspaceRepo repository.WorkspaceRepository,
) *QuotaService {
return &QuotaService{
quotaRepo: quotaRepo,
instanceRepo: instanceRepo,
workspaceRepo: workspaceRepo,
}
}
// CheckQuota 检查配额是否足够
func (s *QuotaService) CheckQuota(ctx context.Context, workspaceID string, cpu, gpu, gpuMemory float64) error {
// 检查 CPU 配额
if cpu > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceCPU)
if err != nil {
return err
}
if quota != nil && !quota.CanAllocate(cpu) {
return entity.ErrQuotaExceeded
}
}
// 检查 GPU 配额
if gpu > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceGPU)
if err != nil {
return err
}
if quota != nil && !quota.CanAllocate(gpu) {
return entity.ErrQuotaExceeded
}
}
// 检查 GPU Memory 配额
if gpuMemory > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceGPUMemory)
if err != nil {
return err
}
if quota != nil && !quota.CanAllocate(gpuMemory) {
return entity.ErrQuotaExceeded
}
}
return nil
}
// AllocateQuota 分配配额(部署实例成功后调用)
func (s *QuotaService) AllocateQuota(ctx context.Context, workspaceID string, cpu, gpu, gpuMemory float64) error {
// 分配 CPU
if cpu > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceCPU)
if err != nil {
return err
}
if quota != nil {
quota.Allocate(cpu)
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
// 分配 GPU
if gpu > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceGPU)
if err != nil {
return err
}
if quota != nil {
quota.Allocate(gpu)
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
// 分配 GPU Memory
if gpuMemory > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceGPUMemory)
if err != nil {
return err
}
if quota != nil {
quota.Allocate(gpuMemory)
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
return nil
}
// ReleaseQuota 释放配额(删除实例后调用)
func (s *QuotaService) ReleaseQuota(ctx context.Context, workspaceID string, cpu, gpu, gpuMemory float64) error {
// 释放 CPU
if cpu > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceCPU)
if err != nil {
return err
}
if quota != nil {
quota.Release(cpu)
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
// 释放 GPU
if gpu > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceGPU)
if err != nil {
return err
}
if quota != nil {
quota.Release(gpu)
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
// 释放 GPU Memory
if gpuMemory > 0 {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, entity.ResourceGPUMemory)
if err != nil {
return err
}
if quota != nil {
quota.Release(gpuMemory)
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
return nil
}
// GetQuotaUsage 获取配额使用情况
func (s *QuotaService) GetQuotaUsage(ctx context.Context, workspaceID string) (map[entity.ResourceType]*entity.WorkspaceQuota, error) {
quotas, err := s.quotaRepo.GetByWorkspace(ctx, workspaceID)
if err != nil {
return nil, err
}
result := make(map[entity.ResourceType]*entity.WorkspaceQuota)
for _, q := range quotas {
result[q.ResourceType] = q
}
// 确保所有资源类型都有返回值
for _, rt := range []entity.ResourceType{entity.ResourceCPU, entity.ResourceGPU, entity.ResourceGPUMemory} {
if _, ok := result[rt]; !ok {
result[rt] = &entity.WorkspaceQuota{
WorkspaceID: workspaceID,
ResourceType: rt,
HardLimit: 0,
SoftLimit: 0,
Used: 0,
}
}
}
return result, nil
}
// RecalculateQuota 重新计算配额使用量(从实例汇总)
func (s *QuotaService) RecalculateQuota(ctx context.Context, workspaceID string) error {
// 获取 workspace 的所有实例
instances, err := s.instanceRepo.GetByWorkspace(ctx, workspaceID)
if err != nil {
return err
}
// 汇总资源使用
var totalCPU, totalGPU, totalGPUMemory float64
for _, inst := range instances {
totalCPU += inst.CPURequested
totalGPU += inst.GPURequested
// GPU Memory 需要解析字符串
// 这里简化处理,实际需要解析 "16Gi" 这样的格式
}
// 更新配额
resources := []entity.ResourceType{entity.ResourceCPU, entity.ResourceGPU, entity.ResourceGPUMemory}
values := []float64{totalCPU, totalGPU, totalGPUMemory}
for i, rt := range resources {
quota, err := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, rt)
if err != nil {
return err
}
if quota != nil {
quota.Used = values[i]
if err := s.quotaRepo.Update(ctx, quota); err != nil {
return err
}
}
}
return nil
}

View File

@ -2,6 +2,8 @@ package service
import (
"context"
"os"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
@ -40,6 +42,13 @@ func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.R
return entity.ErrRegistryExists
}
// 非 mock 模式下验证连接
if os.Getenv("ADAPTER_MODE") != "mock" {
if err := s.ociClient.CheckHealth(ctx, registry); err != nil {
return err
}
}
return s.registryRepo.Create(ctx, registry)
}

View File

@ -0,0 +1,116 @@
package service
import (
"context"
"errors"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
var (
ErrStorageNotFound = errors.New("storage backend not found")
ErrStorageExists = errors.New("storage backend already exists")
)
// StorageService 存储后端领域服务
type StorageService struct {
storageRepo repository.StorageRepository
}
// NewStorageService 创建存储后端服务
func NewStorageService(storageRepo repository.StorageRepository) *StorageService {
return &StorageService{
storageRepo: storageRepo,
}
}
// Create 创建存储后端
func (s *StorageService) Create(
ctx context.Context,
workspaceID, ownerID, name string,
storageType entity.StorageType,
config entity.StorageConfig,
description string,
isDefault, isShared bool,
) (*entity.StorageBackend, error) {
// 检查名称是否已存在
existing, _ := s.storageRepo.GetByName(ctx, workspaceID, name)
if existing != nil {
return nil, ErrStorageExists
}
storage := entity.NewStorageBackend(workspaceID, ownerID, name, storageType, config)
storage.Description = description
storage.IsDefault = isDefault
storage.IsShared = isShared
if err := s.storageRepo.Create(ctx, storage); err != nil {
return nil, err
}
return storage, nil
}
// GetByID 获取存储后端
func (s *StorageService) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
storage, err := s.storageRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrStorageNotFound
}
return storage, nil
}
// GetByWorkspace 获取工作空间的所有存储后端
func (s *StorageService) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
return s.storageRepo.GetByWorkspace(ctx, workspaceID)
}
// GetShared 获取所有共享存储后端
func (s *StorageService) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
return s.storageRepo.GetShared(ctx)
}
// GetDefault 获取工作空间的默认存储后端
func (s *StorageService) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
return s.storageRepo.GetDefault(ctx, workspaceID)
}
// Update 更新存储后端
func (s *StorageService) Update(
ctx context.Context,
id, name, description string,
storageType entity.StorageType,
config entity.StorageConfig,
isDefault, isShared bool,
) (*entity.StorageBackend, error) {
storage, err := s.storageRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrStorageNotFound
}
if name != "" {
storage.Name = name
}
storage.Description = description
storage.Type = storageType
storage.Config = config
storage.IsDefault = isDefault
storage.IsShared = isShared
if err := s.storageRepo.Update(ctx, storage); err != nil {
return nil, err
}
return storage, nil
}
// Delete 删除存储后端
func (s *StorageService) Delete(ctx context.Context, id string) error {
return s.storageRepo.Delete(ctx, id)
}
// List 列出所有存储后端(管理员用)
func (s *StorageService) List(ctx context.Context) ([]*entity.StorageBackend, error) {
return s.storageRepo.List(ctx)
}

View File

@ -0,0 +1,298 @@
package service
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// UserManagementService 用户管理领域服务(仅 Admin 可用)
type UserManagementService struct {
userRepo repository.UserRepository
workspaceRepo repository.WorkspaceRepository
passwordHasher PasswordHasher
}
// NewUserManagementService 创建用户管理服务
func NewUserManagementService(
userRepo repository.UserRepository,
workspaceRepo repository.WorkspaceRepository,
passwordHasher PasswordHasher,
) *UserManagementService {
return &UserManagementService{
userRepo: userRepo,
workspaceRepo: workspaceRepo,
passwordHasher: passwordHasher,
}
}
// CreateUser 创建用户Admin 操作)
func (s *UserManagementService) CreateUser(ctx context.Context, username, password, email, role string, workspaceID string) (*entity.User, error) {
// 检查用户是否已存在
existing, _ := s.userRepo.GetByUsername(ctx, username)
if existing != nil {
return nil, entity.ErrUserExists
}
// 验证角色
if role != string(entity.RoleAdmin) && role != string(entity.RoleUser) {
return nil, fmt.Errorf("invalid role: %s", role)
}
// 如果指定了 workspace验证 workspace 存在
if workspaceID != "" {
_, err := s.workspaceRepo.GetByID(ctx, workspaceID)
if err != nil {
if err == entity.ErrWorkspaceNotFound {
return nil, entity.ErrWorkspaceNotFound
}
return nil, err
}
}
// Admin 不能分配到 workspace
if role == string(entity.RoleAdmin) && workspaceID != "" {
workspaceID = ""
}
// 哈希密码
passwordHash, err := s.passwordHasher.Hash(password)
if err != nil {
return nil, err
}
// 生成占位邮箱
if email == "" {
email = username + "@local.ocdp"
}
// 创建用户
user := entity.NewUser(username, passwordHash, email)
user.ID = uuid.New().String()
user.Role = entity.UserRole(role)
user.WorkspaceID = workspaceID
user.IsActive = true
user.MustChangePassword = true // 首次登录必须修改密码
if err := user.Validate(); err != nil {
return nil, err
}
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// GetUser 获取用户
func (s *UserManagementService) GetUser(ctx context.Context, id string) (*entity.User, error) {
return s.userRepo.GetByID(ctx, id)
}
// ListUsers 列出用户(可筛选 workspace
func (s *UserManagementService) ListUsers(ctx context.Context, workspaceID string) ([]*entity.User, error) {
if workspaceID != "" {
return s.userRepo.ListByWorkspace(ctx, workspaceID)
}
return s.userRepo.List(ctx)
}
// UpdateUser 更新用户信息
func (s *UserManagementService) UpdateUser(ctx context.Context, user *entity.User) error {
return s.userRepo.Update(ctx, user)
}
// SetUserActive 启用/禁用用户
func (s *UserManagementService) SetUserActive(ctx context.Context, userID string, isActive bool) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
user.IsActive = isActive
return s.userRepo.Update(ctx, user)
}
// ChangeUserWorkspace 分配用户到 workspace
func (s *UserManagementService) ChangeUserWorkspace(ctx context.Context, userID, workspaceID string) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
// Admin 不能分配到 workspace
if user.Role == entity.RoleAdmin {
return fmt.Errorf("admin user cannot be assigned to workspace")
}
// 验证 workspace 存在
if workspaceID != "" {
_, err := s.workspaceRepo.GetByID(ctx, workspaceID)
if err != nil {
if err == sql.ErrNoRows || err == entity.ErrWorkspaceNotFound {
return entity.ErrWorkspaceNotFound
}
return err
}
}
user.WorkspaceID = workspaceID
return s.userRepo.Update(ctx, user)
}
// ResetPassword 重置用户密码Admin 操作)
func (s *UserManagementService) ResetPassword(ctx context.Context, userID, newPassword string) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
// 哈希新密码
passwordHash, err := s.passwordHasher.Hash(newPassword)
if err != nil {
return err
}
// 更新密码并设置必须修改密码标志
user.PasswordHash = passwordHash
user.MustChangePassword = true
user.RevokeAllTokens() // 强制登出所有会话
return s.userRepo.Update(ctx, user)
}
// DeleteUser 删除用户
func (s *UserManagementService) DeleteUser(ctx context.Context, id string) error {
return s.userRepo.Delete(ctx, id)
}
// GetUserWithWorkspace 获取用户及其 workspace 信息
type UserWithWorkspace struct {
User *entity.User
Workspace *entity.Workspace
}
// GetUserWithWorkspace 获取用户及其 workspace 信息
func (s *UserManagementService) GetUserWithWorkspace(ctx context.Context, userID string) (*UserWithWorkspace, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, err
}
result := &UserWithWorkspace{
User: user,
}
if user.WorkspaceID != "" {
workspace, _ := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
result.Workspace = workspace
}
return result, nil
}
// ListUsersWithWorkspace 列出用户及其 workspace 信息
func (s *UserManagementService) ListUsersWithWorkspace(ctx context.Context) ([]*UserWithWorkspace, error) {
users, err := s.userRepo.List(ctx)
if err != nil {
return nil, err
}
// 预加载所有 workspace
workspaces, err := s.workspaceRepo.List(ctx)
if err != nil {
return nil, err
}
workspaceMap := make(map[string]*entity.Workspace)
for _, w := range workspaces {
workspaceMap[w.ID] = w
}
result := make([]*UserWithWorkspace, len(users))
for i, user := range users {
result[i] = &UserWithWorkspace{
User: user,
}
if user.WorkspaceID != "" {
result[i].Workspace = workspaceMap[user.WorkspaceID]
}
}
return result, nil
}
// EnsureAdminExists 确保存在一个 Admin 用户
func (s *UserManagementService) EnsureAdminExists(ctx context.Context, defaultPassword string) error {
users, err := s.userRepo.List(ctx)
if err != nil {
return err
}
// 检查是否已有 admin
for _, u := range users {
if u.Role == entity.RoleAdmin {
return nil
}
}
// 创建默认 admin 用户
_, err = s.CreateUser(ctx, "admin", defaultPassword, "", string(entity.RoleAdmin), "")
return err
}
// CreateInitialUser 创建初始用户(首次启动时调用)
func (s *UserManagementService) CreateInitialUser(ctx context.Context, username, password, role string) (*entity.User, error) {
// 检查是否已有用户
users, err := s.userRepo.List(ctx)
if err != nil {
return nil, err
}
if len(users) > 0 {
return nil, fmt.Errorf("initial user already exists")
}
// 验证角色
if role != string(entity.RoleAdmin) && role != string(entity.RoleUser) {
return nil, fmt.Errorf("invalid role: %s", role)
}
// 哈希密码
passwordHash, err := s.passwordHasher.Hash(password)
if err != nil {
return nil, err
}
// 生成占位邮箱
email := username + "@local.ocdp"
// 创建用户
user := entity.NewUser(username, passwordHash, email)
user.ID = uuid.New().String()
user.Role = entity.UserRole(role)
// workspace_id 为 NULLadmin或空首个普通用户
user.IsActive = true
user.MustChangePassword = false // 初始用户不需要强制修改密码
if err := user.Validate(); err != nil {
return nil, err
}
// 设置创建时间和更新时间
now := time.Now()
user.CreatedAt = now
user.UpdatedAt = now
if err := s.userRepo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}

View File

@ -0,0 +1,143 @@
package service
import (
"context"
"errors"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
var (
ErrTemplateNotFound = errors.New("template not found")
ErrTemplateExists = errors.New("template already exists")
)
// ValuesTemplateService Values 模板领域服务
type ValuesTemplateService struct {
valuesTemplateRepo repository.ValuesTemplateRepository
chartRefRepo repository.ChartReferenceRepository
}
// NewValuesTemplateService 创建 Values 模板服务
func NewValuesTemplateService(
valuesTemplateRepo repository.ValuesTemplateRepository,
chartRefRepo repository.ChartReferenceRepository,
) *ValuesTemplateService {
return &ValuesTemplateService{
valuesTemplateRepo: valuesTemplateRepo,
chartRefRepo: chartRefRepo,
}
}
// Create 创建 Values 模板
func (s *ValuesTemplateService) Create(
ctx context.Context,
workspaceID, ownerID, chartRefID, name, description, valuesYAML string,
isDefault bool,
) (*entity.ValuesTemplate, error) {
// 检查 Chart Reference 是否存在
chartRef, err := s.chartRefRepo.GetByID(ctx, chartRefID)
if err != nil {
return nil, errors.New("chart reference not found")
}
// 检查名称是否已存在
existing, _ := s.valuesTemplateRepo.GetByName(ctx, workspaceID, chartRefID, name)
if existing != nil {
return nil, ErrTemplateExists
}
template := entity.NewValuesTemplate(workspaceID, ownerID, chartRef.ID, name, valuesYAML)
template.Description = description
template.IsDefault = isDefault
if err := s.valuesTemplateRepo.Create(ctx, template); err != nil {
return nil, err
}
return template, nil
}
// GetByID 获取 Values 模板
func (s *ValuesTemplateService) GetByID(ctx context.Context, id string) (*entity.ValuesTemplate, error) {
template, err := s.valuesTemplateRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrTemplateNotFound
}
return template, nil
}
// GetByWorkspace 获取工作空间的所有 Values 模板
func (s *ValuesTemplateService) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ValuesTemplate, error) {
return s.valuesTemplateRepo.GetByWorkspace(ctx, workspaceID)
}
// GetByChartReference 获取 Chart Reference 的所有 Values 模板
func (s *ValuesTemplateService) GetByChartReference(ctx context.Context, chartRefID string) ([]*entity.ValuesTemplate, error) {
return s.valuesTemplateRepo.GetByChartReference(ctx, chartRefID)
}
// GetHistory 获取模板的版本历史
func (s *ValuesTemplateService) GetHistory(ctx context.Context, chartRefID, name string) ([]*entity.ValuesTemplate, error) {
return s.valuesTemplateRepo.GetHistory(ctx, chartRefID, name)
}
// Update 更新 Values 模板(创建新版本)
func (s *ValuesTemplateService) Update(
ctx context.Context,
id, description, valuesYAML string,
isDefault bool,
) (*entity.ValuesTemplate, error) {
template, err := s.valuesTemplateRepo.GetByID(ctx, id)
if err != nil {
return nil, ErrTemplateNotFound
}
template.Description = description
template.ValuesYAML = valuesYAML
template.IsDefault = isDefault
if err := s.valuesTemplateRepo.Update(ctx, template); err != nil {
return nil, err
}
// 获取最新版本
return s.valuesTemplateRepo.GetByName(ctx, template.WorkspaceID, template.ChartReferenceID, template.Name)
}
// Delete 删除 Values 模板
func (s *ValuesTemplateService) Delete(ctx context.Context, id string) error {
return s.valuesTemplateRepo.Delete(ctx, id)
}
// List 列出所有 Values 模板(管理员用)
func (s *ValuesTemplateService) List(ctx context.Context) ([]*entity.ValuesTemplate, error) {
return s.valuesTemplateRepo.List(ctx)
}
// Rollback 回滚到指定版本
func (s *ValuesTemplateService) Rollback(ctx context.Context, templateID string) (*entity.ValuesTemplate, error) {
// 获取历史版本模板
oldTemplate, err := s.valuesTemplateRepo.GetByID(ctx, templateID)
if err != nil {
return nil, ErrTemplateNotFound
}
// 重新创建该版本(创建新版本,内容与旧版本相同)
newTemplate := &entity.ValuesTemplate{
WorkspaceID: oldTemplate.WorkspaceID,
OwnerID: oldTemplate.OwnerID,
ChartReferenceID: oldTemplate.ChartReferenceID,
Name: oldTemplate.Name,
Description: oldTemplate.Description,
ValuesYAML: oldTemplate.ValuesYAML,
}
if err := s.valuesTemplateRepo.Update(ctx, newTemplate); err != nil {
return nil, err
}
// 获取最新版本
return s.valuesTemplateRepo.GetByName(ctx, newTemplate.WorkspaceID, newTemplate.ChartReferenceID, newTemplate.Name)
}

View File

@ -0,0 +1,121 @@
package service
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// WorkspaceService 工作空间领域服务
type WorkspaceService struct {
workspaceRepo repository.WorkspaceRepository
quotaRepo repository.QuotaRepository
userRepo repository.UserRepository
}
// NewWorkspaceService 创建工作空间服务
func NewWorkspaceService(
workspaceRepo repository.WorkspaceRepository,
quotaRepo repository.QuotaRepository,
userRepo repository.UserRepository,
) *WorkspaceService {
return &WorkspaceService{
workspaceRepo: workspaceRepo,
quotaRepo: quotaRepo,
userRepo: userRepo,
}
}
// Create 创建工作空间
func (s *WorkspaceService) Create(ctx context.Context, name, description, createdBy string) (*entity.Workspace, error) {
// 检查名称是否已存在
existing, _ := s.workspaceRepo.GetByName(ctx, name)
if existing != nil {
return nil, entity.ErrWorkspaceExists
}
workspace := entity.NewWorkspace(name, description, createdBy)
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
return nil, err
}
return workspace, nil
}
// GetByID 获取工作空间
func (s *WorkspaceService) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
return s.workspaceRepo.GetByID(ctx, id)
}
// GetByName 获取工作空间
func (s *WorkspaceService) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
return s.workspaceRepo.GetByName(ctx, name)
}
// Update 更新工作空间
func (s *WorkspaceService) Update(ctx context.Context, workspace *entity.Workspace) error {
return s.workspaceRepo.Update(ctx, workspace)
}
// Delete 删除工作空间
func (s *WorkspaceService) Delete(ctx context.Context, id string) error {
// 删除关联的配额
if err := s.quotaRepo.DeleteByWorkspace(ctx, id); err != nil {
return err
}
return s.workspaceRepo.Delete(ctx, id)
}
// List 列出所有工作空间
func (s *WorkspaceService) List(ctx context.Context) ([]*entity.Workspace, error) {
return s.workspaceRepo.List(ctx)
}
// GetQuotas 获取工作空间配额
func (s *WorkspaceService) GetQuotas(ctx context.Context, workspaceID string) ([]*entity.WorkspaceQuota, error) {
return s.quotaRepo.GetByWorkspace(ctx, workspaceID)
}
// SetQuota 设置配额
func (s *WorkspaceService) SetQuota(ctx context.Context, workspaceID string, resourceType entity.ResourceType, hardLimit, softLimit float64) (*entity.WorkspaceQuota, error) {
quota := entity.NewWorkspaceQuota(workspaceID, resourceType, hardLimit, softLimit)
if err := s.quotaRepo.Create(ctx, quota); err != nil {
return nil, err
}
return quota, nil
}
// SetQuotas 批量设置配额
func (s *WorkspaceService) SetQuotas(ctx context.Context, workspaceID string, quotas map[entity.ResourceType]struct {
HardLimit float64
SoftLimit float64
}) error {
for resourceType, config := range quotas {
quota := entity.NewWorkspaceQuota(workspaceID, resourceType, config.HardLimit, config.SoftLimit)
if err := s.quotaRepo.Create(ctx, quota); err != nil {
return err
}
}
return nil
}
// GetOrCreateDefaultQuota 获取或创建默认配额
func (s *WorkspaceService) GetOrCreateDefaultQuota(ctx context.Context, workspaceID string, resourceType entity.ResourceType) (*entity.WorkspaceQuota, error) {
quota, _ := s.quotaRepo.GetByWorkspaceAndType(ctx, workspaceID, resourceType)
if quota != nil {
return quota, nil
}
// 创建默认配额(无限制)
quota = entity.NewWorkspaceQuota(workspaceID, resourceType, 0, 0)
if err := s.quotaRepo.Create(ctx, quota); err != nil {
return nil, err
}
return quota, nil
}
// GetUsers 获取工作空间的用户
func (s *WorkspaceService) GetUsers(ctx context.Context, workspaceID string) ([]*entity.User, error) {
return s.userRepo.ListByWorkspace(ctx, workspaceID)
}