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

@ -1,79 +0,0 @@
# Project Structure
## 📁 Directory Layout
```
ocdp-go/
├── backend/ # Go 后端
│ ├── cmd/api/ # 主程序入口
│ ├── internal/
│ │ ├── handlers/ # HTTP handlers
│ │ ├── graphql/ # GraphQL schema & resolvers
│ │ ├── models/ # 数据模型
│ │ ├── storage/ # 存储层JSON
│ │ └── auth/ # 认证
│ └── data/ # 数据存储JSON文件
└── frontend/ # React 前端
└── src/
├── app/ # 应用层
│ ├── providers/ # 全局 Provider
│ ├── routes/ # 路由配置
│ └── constants/ # 导航配置
├── features/ # 功能模块(按类别)
│ ├── configuration/ # 🔧 配置类
│ │ ├── clusters/
│ │ └── registries/
│ ├── monitoring/ # 📊 监控类
│ │ └── clusters/
│ ├── artifact/ # 📦 制品类
│ │ ├── registries/
│ │ └── instances/
│ ├── auth/
│ └── home/
├── core/ # 核心层
│ ├── api/ # API 调用
│ ├── graphql/ # GraphQL 客户端
│ ├── types/ # 类型定义
│ └── config/ # 配置
└── shared/ # 共享层
├── components/ # 通用组件
├── hooks/ # 通用 hooks
├── utils/ # 工具函数
└── services/ # 服务
```
## 🎯 Features 模块分类
### Configuration配置类
- `clusters/` - 配置 K8s 集群连接
- `registries/` - 配置 OCI 仓库连接
### Monitoring监控类
- `clusters/` - 监控集群状态
### Artifact制品类
- `registries/` - 浏览制品仓库内容
- `instances/` - 管理已部署的服务实例
## 🔄 API 架构
- **GraphQL** - 用于 CRUD 操作(集群、仓库、实例)
- **RESTful** - 用于代理和实时查询OCI、K8s 状态)
- **Unified API** - 自动根据配置切换 GraphQL/RESTful
## 🚀 实例管理术语
- **Launch** - 启动/创建服务实例
- **Modify** - 修改实例配置
- **Terminate** - 终止实例运行
## 📝 说明
- 所有功能模块按**业务类别**分类(配置、监控、制品)
- Features 模块名使用**单数**形式configuration, artifact
- URL 路径保持 RESTful 风格(`/artifact/registries`

46
CLAUDE.md Normal file
View File

@ -0,0 +1,46 @@
# Project Overview
# 🤖 Claude Code Agentic Workflow (Strictly Follow)
作为本项目的资深 AI 研发工程师,你在执行任何指令时,必须严格遵守以下核心原则与工作流。
## . 核心原则 (Core Principles)
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky在掌握全局上下文后用优雅的方式重构它但不要过度设计
## Ⅱ. 任务管理闭环 (Task Management Protocol)
你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态:
1. **Plan First:** 在开始实现前,将计划写入 `tasks/todo.md`,必须是可勾选的 Checkbox 列表。
2. **Verify Plan:** 在动手写代码前先和我User确认这个计划是否合理。
3. **Track Progress:** 边做边在 `todo.md` 中打勾标记完成状态。
4. **Explain Changes:** 在每执行完一个步骤时,给出高层次的代码修改总结。
5. **Document Results:** 任务完成后,在 `todo.md` 中补充 Review 总结。
6. **Capture Lessons:** 如果被我纠正了错误,立刻更新 `tasks/lessons.md`
## Ⅲ. 工作流编排 (Workflow Orchestration)
### 1. 强制规划模式 (Plan Node Default)
- 对于任何非琐碎任务(涉及 3 个以上步骤或架构决策),必须进入规划模式。
- 提前写好详细的 Spec 以减少歧义。
- **一旦情况不对劲(报错连连),立即停止盲目推进**,重新评估并制定新计划。
### 2. 经验自我迭代 (Self-Improvement Loop)
- 在每次会话开始时,主动读取 `tasks/lessons.md`,复习该项目的历史教训。
- 针对犯过的错误,为自己制定防止再次踩坑的规则。
- 无情地迭代这些经验,直到你的错误率显著下降。
### 3. 自主修复 Bug (Autonomous Bug Fixing)
- 当我给你一个 Bug 报告时:**直接去修。不要等我手把手教你。**
- 主动利用 CLI 权限去查看日志、定位错误代码、运行失败的测试用例,然后解决它。
- 要求对用户“零上下文切换”——你去修复 CI 测试,不需要我告诉你具体该怎么做。
### 4. 交付前绝对验证 (Verification Before Done)
- **永远不要在没有证明代码能跑的情况下,把任务标记为“完成”。**
- 问自己“Staff Engineer主任工程师会批准这段代码吗
- 必须主动运行测试(例如 `go test`, `npm run build`),检查日志,并向我证明正确性。
- 对比修改前后的 Diff确保行为符合预期。
### 5. 复杂问题拆解 (Agentic Strategy)
- 遇到极其复杂的问题时,不要试图在一个终端窗口内硬扛。
- 拆解子任务,主动进行探索性研究,针对焦点问题逐一击破。

View File

@ -1,543 +0,0 @@
# OCDP 命令速查表
快速查找常用的 Docker 和 Make 命令。
---
## 🚀 快速启动
```bash
# 开发环境推荐Mock 模式)
make docker-dev
# 生产环境(真实数据库)
make docker-prod
# 测试后端(独立)
make docker-test-backend
# 测试前端(独立)
make docker-test-frontend
```
---
## 📋 完整命令列表
### Docker 服务管理
| 命令 | 说明 | 模式 |
|------|------|------|
| `make docker-dev` | 启动开发环境(前台) | Dev |
| `make docker-dev-bg` | 启动开发环境(后台) | Dev |
| `make docker-prod` | 启动生产环境 | Production |
| `make docker-up` | 同 docker-prod | Production |
| `make docker-down` | 停止所有服务 | - |
| `make docker-down-v` | 停止并删除数据卷 | - |
### 服务测试
| 命令 | 说明 | 端口 |
|------|------|------|
| `make docker-test-backend` | 测试后端(前台) | 8080 |
| `make docker-test-backend-bg` | 测试后端(后台) | 8080 |
| `make docker-test-frontend` | 测试前端(前台) | 3000 |
| `make docker-test-frontend-bg` | 测试前端(后台) | 3000 |
### 日志查看
| 命令 | 说明 |
|------|------|
| `make docker-logs` | 查看所有服务日志 |
| `make docker-logs-backend` | 只看后端日志 |
| `make docker-logs-frontend` | 只看前端日志 |
| `make docker-logs-db` | 只看数据库日志 |
### 服务状态
| 命令 | 说明 |
|------|------|
| `make docker-ps` | 查看服务状态 |
| `make docker-status` | 查看详细状态(含健康检查) |
### 镜像管理
| 命令 | 说明 |
|------|------|
| `make docker-build` | 构建所有镜像 |
| `make docker-build-no-cache` | 无缓存构建 |
| `make docker-restart` | 重启所有服务 |
| `make docker-restart-backend` | 只重启后端 |
| `make docker-restart-frontend` | 只重启前端 |
### 工具服务
| 命令 | 说明 | 端口 |
|------|------|------|
| `make docker-tools` | 启动 pgAdmin + Swagger UI | 5050, 8081 |
---
## 🔧 原始 Docker Compose 命令
### 开发模式
```bash
# 启动(前台)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# 启动(后台)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# 停止
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
# 查看日志
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f
# 重启服务
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart backend
```
### 生产模式
```bash
# 启动(前台)
docker compose up
# 启动(后台)
docker compose up -d
# 停止
docker compose down
# 查看日志
docker compose logs -f
# 重启服务
docker compose restart backend
```
### Mock 模式
```bash
# 测试后端
docker compose -f docker-compose.mock.yml up backend
# 测试前端
docker compose -f docker-compose.mock.yml up frontend
# 后台运行
docker compose -f docker-compose.mock.yml up -d backend
# 停止
docker compose -f docker-compose.mock.yml down
```
---
## 🛠️ 本地开发命令(不使用 Docker
### 安装依赖
```bash
make install # 安装所有依赖
make install-tools # 安装 OpenAPI 工具
make install-deps # 安装项目依赖
```
### 启动服务
```bash
make dev-local # 启动完整环境
make dev-backend-mock # 只启动后端Mock
make dev-backend-prod # 只启动后端(生产)
make dev-frontend # 只启动前端
```
### OpenAPI 工作流
```bash
make openapi-validate # 验证 OpenAPI 规范
make openapi-gen # 生成前后端代码
make openapi-gen-backend # 只生成后端代码
make openapi-gen-frontend # 只生成前端代码
make openapi-docs # 生成 HTML 文档
make openapi-view # 启动文档服务器
```
### 测试
```bash
make test # 运行所有测试
make test-backend # 运行后端测试
make test-frontend # 运行前端测试
```
### 清理
```bash
make clean # 清理构建产物
make clean-generated # 清理生成的代码
```
---
## 📊 常用 Docker 命令
### 容器操作
```bash
# 进入容器
docker compose exec backend sh
docker compose exec frontend sh
docker compose exec postgres psql -U postgres -d ocdp
# 查看容器列表
docker compose ps
# 查看容器详情
docker compose ps -a
# 删除容器
docker compose rm backend
docker compose rm -f backend # 强制删除
# 停止特定容器
docker compose stop backend
docker compose stop frontend
```
### 日志操作
```bash
# 实时日志
docker compose logs -f
# 只看特定服务
docker compose logs -f backend
# 显示最后 N 行
docker compose logs --tail=100 backend
# 显示时间戳
docker compose logs -f -t backend
# 不跟随(只显示现有)
docker compose logs backend
```
### 镜像操作
```bash
# 构建镜像
docker compose build
# 无缓存构建
docker compose build --no-cache
# 只构建特定服务
docker compose build backend
docker compose build frontend
# 查看镜像
docker images | grep ocdp
# 删除镜像
docker rmi ocdp-backend
docker rmi ocdp-frontend
```
### 数据卷操作
```bash
# 查看数据卷
docker volume ls
# 删除特定数据卷
docker volume rm ocdp_postgres_data
docker volume rm ocdp_redis_data
# 删除所有未使用的数据卷
docker volume prune
# 备份数据卷
docker run --rm -v ocdp_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz -C /data .
# 恢复数据卷
docker run --rm -v ocdp_postgres_data:/data -v $(pwd):/backup alpine sh -c "cd /data && tar xzf /backup/postgres_backup.tar.gz"
```
### 网络操作
```bash
# 查看网络
docker network ls
# 查看网络详情
docker network inspect ocdp-network
# 测试网络连通性
docker compose exec backend ping frontend
docker compose exec frontend ping backend
```
---
## 🔍 调试命令
### 健康检查
```bash
# 后端健康检查
curl http://localhost:8080/health
# 前端健康检查
curl http://localhost:5173/ # Dev 模式
curl http://localhost:3000/ # Production 模式
# 数据库健康检查
docker compose exec postgres pg_isready -U postgres
```
### API 测试
```bash
# 登录
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# 获取 Registries
curl http://localhost:8080/api/v1/registries
# 获取 Clusters
curl http://localhost:8080/api/v1/clusters
# 获取 Repositories
curl http://localhost:8080/api/v1/registries/harbor-prod/repositories
# 获取 Artifacts带过滤
curl "http://localhost:8080/api/v1/registries/harbor-prod/repositories/charts%2Fvllm-serve/artifacts?media_type=chart"
```
### 数据库操作
```bash
# 连接数据库
docker compose exec postgres psql -U postgres -d ocdp
# 查看表
docker compose exec postgres psql -U postgres -d ocdp -c "\dt"
# 查询数据
docker compose exec postgres psql -U postgres -d ocdp -c "SELECT * FROM registries;"
# 执行 SQL 文件
docker compose exec -T postgres psql -U postgres -d ocdp < schema.sql
```
### 性能监控
```bash
# 查看资源使用
docker stats
# 只看特定容器
docker stats ocdp-backend ocdp-frontend
# 查看容器进程
docker compose top backend
docker compose top frontend
```
---
## 🎯 常用组合命令
### 完全重置
```bash
# 停止并删除所有内容
docker compose down -v
docker compose -f docker-compose.dev.yml down -v
docker compose -f docker-compose.mock.yml down -v
# 删除镜像
docker rmi $(docker images | grep ocdp | awk '{print $3}')
# 重新构建
make docker-build
# 重新启动
make docker-dev
```
### 更新代码后重新部署
```bash
# 停止服务
make docker-down
# 拉取最新代码
git pull
# 重新构建
make docker-build
# 启动服务
make docker-dev
```
### 查看完整系统状态
```bash
# 查看所有容器
docker compose ps
# 查看所有日志
make docker-logs
# 查看健康状态
make docker-status
# 测试所有服务
curl http://localhost:8080/health
curl http://localhost:5173/
curl http://localhost:5432 # 数据库端口
```
---
## 📝 环境变量
### 后端环境变量
```bash
# Mock 模式
export ADAPTER_MODE=mock
export JWT_SECRET=dev-secret
export ENCRYPTION_KEY=dev-encryption-key-32-bytes-long
# Production 模式
export ADAPTER_MODE=production
export DATABASE_URL=postgresql://postgres:postgres@postgres:5432/ocdp?sslmode=disable
export JWT_SECRET=your-production-secret
export ENCRYPTION_KEY=your-production-encryption-key-32-bytes
```
### 前端环境变量
```bash
export VITE_API_BASE_URL=http://localhost:8080/api/v1
export VITE_USE_MOCK=false
```
---
## 🆘 故障排查命令
### 问题诊断
```bash
# 查看完整日志
docker compose logs --tail=1000 > debug.log
# 查看特定时间段日志
docker compose logs --since 10m backend
# 查看容器详细信息
docker compose exec backend env
# 检查配置
docker compose config
# 验证 docker-compose.yml
docker compose -f docker-compose.yml config
docker compose -f docker-compose.dev.yml config
```
### 端口冲突检查
```bash
# 查看端口占用
sudo lsof -i :8080
sudo lsof -i :5173
sudo lsof -i :3000
sudo lsof -i :5432
# 杀死进程
sudo kill -9 <PID>
```
### 磁盘空间清理
```bash
# 删除未使用的容器
docker container prune
# 删除未使用的镜像
docker image prune
# 删除未使用的数据卷
docker volume prune
# 删除所有未使用的资源
docker system prune -a --volumes
```
---
## 💡 实用技巧
### 1. 后台运行并查看日志
```bash
make docker-dev-bg && make docker-logs
```
### 2. 重启并查看日志
```bash
docker compose restart backend && docker compose logs -f backend
```
### 3. 构建并启动
```bash
make docker-build && make docker-dev
```
### 4. 清理并重新开始
```bash
make docker-down && docker system prune -f && make docker-dev
```
### 5. 快速测试 API
```bash
# 保存 token
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' | jq -r '.token')
# 使用 token
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/registries
```
---
## 📚 更多资源
- [快速开始](./QUICK_START.md)
- [Docker 服务架构](./DOCKER_SERVICES.md)
- [项目重构总结](./PROJECT_RESTRUCTURE_SUMMARY.md)
- [README](./README.md)
---
<div align="center">
<sub>命令速查表 - 最后更新2025-11-09</sub>
</div>

218
Makefile
View File

@ -1,56 +1,192 @@
# ============================================================
# OCDP stack orchestration Makefile
# run-2: 构建前端静态资源 + 启动 nginx统一入口和 backend 栈
# clean-2: 清理 run-2 产生的容器 / 卷 / 网络
# OCDP - Open Cloud Development Platform
# Makefile for Docker Compose deployment
# ============================================================
SHELL := /bin/bash
COMPOSE_BIN ?= docker compose
# ============================================================
# Configuration - Modify these for your environment
# ============================================================
ROOT_COMPOSE := docker-compose.yml
BACKEND_COMPOSE := backend/docker-compose.yml
BACKEND_PROFILE := backend
# Server IP for external access (客户端访问IP)
SERVER_IP ?= 10.6.80.114
COMPOSE_STACK := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE) --profile $(BACKEND_PROFILE)
COMPOSE_STACK_ALL := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE)
STACK_ENV := ADAPTER_MODE=production BACKEND_BUILD_CONTEXT=$(abspath backend) BACKEND_BUILD_DOCKERFILE=$(abspath backend/Dockerfile) BACKEND_MOCK_BUILD_DOCKERFILE=$(abspath backend/Dockerfile.mock) INIT_DB_SQL_PATH=$(abspath backend/scripts/init-db.sql)
# Backend configuration
BACKEND_PORT ?= 8080
JWT_SECRET ?= change-me-in-production
ENCRYPTION_KEY ?= change-me-32-bytes-long-key-here
DATABASE_URL ?= postgresql://postgres:postgres@postgres:5432/ocdp?sslmode=disable
ADAPTER_MODE ?= production
STACK_SERVICES := postgres backend nginx
# Allowed CORS origins (for external access)
# 格式: http://IP:端口,多个用逗号分隔
ALLOWED_ORIGINS ?= http://$(SERVER_IP),http://$(SERVER_IP):3000
.PHONY: run-2 clean-2 build-backend
# Compose files
COMPOSE_FILES := -f docker-compose.yml -f backend/docker-compose.yml
run-2:
@echo "═══════════════════════════════════════════════"
@echo "🚀 run-2: rebuild static assets + start web gateway stack"
@echo "═══════════════════════════════════════════════"
# Database init SQL path (relative to project root)
INIT_DB_SQL_PATH ?= ./backend/scripts/init-db.sql
# ============================================================
# Production Commands (Docker Compose)
# ============================================================
.PHONY: up down restart clean logs logs-backend logs-frontend status help
# Start all services
up:
@echo "============================================"
@echo "Starting OCDP services..."
@echo "Server IP: $(SERVER_IP)"
@echo "============================================"
@ALLOWED_DEV_ORIGINS="$(ALLOWED_ORIGINS)" \
DATABASE_URL="$(DATABASE_URL)" \
JWT_SECRET="$(JWT_SECRET)" \
ENCRYPTION_KEY="$(ENCRYPTION_KEY)" \
ADAPTER_MODE="$(ADAPTER_MODE)" \
BACKEND_PORT=$(BACKEND_PORT) \
INIT_DB_SQL_PATH="$(INIT_DB_SQL_PATH)" \
docker compose $(COMPOSE_FILES) --profile backend up -d
@echo ""
@export COMPOSE_PROJECT_NAME=ocdp && \
export ADAPTER_MODE=production && \
export BACKEND_BUILD_CONTEXT=$(abspath backend) && \
export BACKEND_BUILD_DOCKERFILE=$(abspath backend/Dockerfile) && \
export BACKEND_MOCK_BUILD_DOCKERFILE=$(abspath backend/Dockerfile.mock) && \
export INIT_DB_SQL_PATH=$(abspath backend/scripts/init-db.sql) && \
echo "→ Rebuilding frontend static assets" && \
$(COMPOSE_STACK) run --rm frontend-build && \
echo "" && \
echo "→ Rebuilding backend image" && \
$(COMPOSE_STACK) build backend && \
echo "" && \
echo "→ Bringing up backend + nginx services" && \
$(COMPOSE_STACK) up -d $(STACK_SERVICES)
@echo "✅ Services started:"
@echo " Frontend: http://$(SERVER_IP)"
@echo " Backend: http://$(SERVER_IP):$(BACKEND_PORT)/api/v1"
@echo " Swagger: http://$(SERVER_IP):$(BACKEND_PORT)/api/docs"
@echo " PostgreSQL: localhost:5432"
@echo ""
@echo "✅ Services online:"
@echo "═══════════════════════════════════════════════"
@echo " Default login: admin / admin123"
@echo "============================================"
clean-2:
@echo "═══════════════════════════════════════════════"
@echo "🧹 clean-2: tearing down run-2 stack"
@echo "═══════════════════════════════════════════════"
@$(COMPOSE_STACK_ALL) down --remove-orphans || true
@$(COMPOSE_STACK_ALL) down -v --remove-orphans || true
@$(COMPOSE_BIN) -f $(BACKEND_COMPOSE) down -v --remove-orphans || true
@echo "✅ Environment cleaned"
@echo "═══════════════════════════════════════════════"
# Stop all services (保留数据)
down:
@echo "Stopping OCDP services..."
@docker compose $(COMPOSE_FILES) down
# Restart all services
restart: down up
# Full cleanup (删除所有数据卷)
clean:
@echo "============================================"
@echo "⚠️ Full cleanup - 警告:此操作将删除所有数据!"
@echo "============================================"
@docker compose $(COMPOSE_FILES) down -v
@echo "✅ All services stopped and data removed"
# ============================================================
# Build Commands
# ============================================================
.PHONY: build build-frontend build-backend rebuild
# Build all images
build:
@docker compose $(COMPOSE_FILES) build
# Rebuild and start (强制重建前端)
rebuild:
@docker compose $(COMPOSE_FILES) up -d --build --force-recreate
# Rebuild frontend only
build-frontend:
@docker compose -f docker-compose.yml build frontend
# Rebuild backend only
build-backend:
@docker compose -f backend/docker-compose.yml build backend
# ============================================================
# Log Commands
# ============================================================
# View all logs
logs:
@docker compose $(COMPOSE_FILES) logs -f
# View backend logs
logs-backend:
@docker logs -f ocdp-backend
# View frontend logs
logs-frontend:
@docker logs -f ocdp-frontend
# View nginx logs
logs-nginx:
@docker logs -f ocdp-nginx
# ============================================================
# Status Commands
# ============================================================
# Show service status
status:
@docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# ============================================================
# Database Commands
# ============================================================
.PHONY: db-reset db-init db-shell
# Reset database (删除并重建)
db-reset:
@echo "Resetting database..."
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -c "DROP DATABASE IF EXISTS ocdp;" || true
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -c "CREATE DATABASE ocdp;" || true
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -d ocdp -c "$$(cat backend/scripts/init-db.sql)"
# Initialize database
db-init:
@docker compose $(COMPOSE_FILES) exec -T postgres psql -U postgres -d ocdp -c "$$(cat backend/scripts/init-db.sql)"
# Open database shell
db-shell:
@docker compose $(COMPOSE_FILES) exec postgres psql -U postgres -d ocdp
# ============================================================
# Help
# ============================================================
help:
@echo "OCDP - Open Cloud Deployment Platform"
@echo ""
@echo "Usage: make [target]"
@echo ""
@echo "Main Commands:"
@echo " make up - 启动所有服务"
@echo " make down - 停止所有服务(保留数据)"
@echo " make restart - 重启所有服务"
@echo " make clean - 完全清理(删除所有数据)"
@echo " make rebuild - 强制重建并启动"
@echo ""
@echo "Build Commands:"
@echo " make build - 构建所有镜像"
@echo " make build-frontend - 只构建前端"
@echo " make build-backend - 只构建后端"
@echo ""
@echo "Log Commands:"
@echo " make logs - 查看所有日志"
@echo " make logs-backend - 只看后端日志"
@echo " make logs-frontend - 只看前端日志"
@echo " make logs-nginx - 只看nginx日志"
@echo ""
@echo "Database Commands:"
@echo " make db-reset - 重置数据库"
@echo " make db-init - 初始化数据库"
@echo " make db-shell - 进入数据库终端"
@echo ""
@echo "Utility Commands:"
@echo " make status - 查看服务状态"
@echo ""
@echo "Environment Variables:"
@echo " SERVER_IP=$(SERVER_IP) - 服务器IP默认: 10.6.80.114"
@echo " BACKEND_PORT=$(BACKEND_PORT) - 后端端口(默认: 8080"
@echo " ALLOWED_ORIGINS=$(ALLOWED_ORIGINS) - 允许的跨域来源"
@echo ""
@echo "Examples:"
@echo " make up SERVER_IP=192.168.1.100 # 自定义IP启动"
@echo " make clean # 完全清理并重新开始"
@echo "============================================"

View File

@ -1,413 +0,0 @@
# OCDP 快速开始指南
## 🚀 5分钟快速体验
### 前置要求
- Docker 20.10+
- Docker Compose 2.0+
- (可选) Make 工具
### 第一步:克隆项目
```bash
git clone <repository-url>
cd ocdp-go
```
### 第二步:选择运行模式
#### 方式 1: 开发模式(推荐用于日常开发)
**特点**
- ✅ 后端 Mock 模式(无需数据库)
- ✅ 热重载(代码修改自动生效)
- ✅ 快速启动
- ✅ 适合快速迭代开发
```bash
# 使用 Make
make docker-dev
# 或直接使用 Docker Compose
docker compose --profile dev up
```
**访问服务**
- 前端http://localhost:5173
- 后端http://localhost:8080
- API 文档http://localhost:8080/api/v1
**默认账号**
- 用户名:`admin`
- 密码:`admin123`
#### 方式 2: 生产模式(用于完整功能测试)
**特点**
- ✅ 真实数据库
- ✅ 完整功能
- ✅ 生产环境配置
```bash
# 使用 Make
make docker-prod
# 或直接使用 Docker Compose
docker compose up -d
```
**访问服务**
- 前端http://localhost:3000
- 后端http://localhost:8080
- 数据库localhost:5432
#### 方式 3: 独立测试单个服务
**测试后端**
```bash
make docker-test-backend
# 访问http://localhost:8080
```
**测试前端**
```bash
make docker-test-frontend
# 访问http://localhost:3000
```
### 第三步:验证服务
```bash
# 检查后端健康状态
curl http://localhost:8080/health
# 登录获取 token
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# 获取 registries 列表
curl http://localhost:8080/api/v1/registries
```
### 第四步:开始开发
#### 修改后端代码
```bash
# 编辑任意 Go 文件
vim backend/cmd/api/main.go
# Air 会自动检测变化并重新编译(开发模式)
# 查看日志确认重载
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backend
```
#### 修改前端代码
```bash
# 编辑任意 React 组件
vim frontend/src/App.tsx
# Vite HMR 会自动更新浏览器(开发模式)
# 无需手动刷新页面
```
### 停止服务
```bash
# 停止并删除容器
make docker-down
# 或
docker compose down
# 完全清理(包括数据卷)
docker compose down -v
```
---
## 📚 常用命令速查
### Docker 命令
| 命令 | 说明 |
|------|------|
| `make docker-dev` | 启动开发环境 |
| `make docker-prod` | 启动生产环境 |
| `make docker-test-backend` | 测试后端 |
| `make docker-test-frontend` | 测试前端 |
| `make docker-logs` | 查看所有日志 |
| `make docker-down` | 停止所有服务 |
| `make docker-build` | 重新构建镜像 |
### 查看日志
```bash
# 所有服务
docker compose logs -f
# 只看后端
docker compose logs -f backend
# 只看前端
docker compose logs -f frontend
# 只看最后 100 行
docker compose logs --tail=100
```
### 进入容器
```bash
# 进入后端容器
docker compose exec backend sh
# 进入前端容器
docker compose exec frontend sh
# 进入数据库容器
docker compose exec postgres psql -U postgres -d ocdp
```
### 重启服务
```bash
# 重启所有
docker compose restart
# 重启后端
docker compose restart backend
# 重启前端
docker compose restart frontend
```
---
## 🎯 使用场景指南
### 场景 1: 我要开发新功能
```bash
# 1. 启动开发环境
make docker-dev
# 2. 修改代码(自动热重载)
# 3. 查看日志
make docker-logs
# 4. 测试功能
# 访问 http://localhost:5173
# 5. 停止
make docker-down
```
### 场景 2: 我要测试完整功能
```bash
# 1. 启动生产环境(包含数据库)
make docker-prod
# 2. 访问前端
# http://localhost:3000
# 3. 停止
make docker-down
```
### 场景 3: 我只想测试后端 API
```bash
# 1. 启动后端 Mock
make docker-test-backend-bg
# 2. 测试 API
curl http://localhost:8080/health
curl http://localhost:8080/api/v1/registries
# 3. 停止
docker compose -f docker-compose.mock.yml down
```
### 场景 4: 我只想测试前端界面
```bash
# 1. 启动前端 Mock
make docker-test-frontend-bg
# 2. 访问前端
# http://localhost:3000
# 3. 停止
docker compose -f docker-compose.mock.yml down
```
### 场景 5: 代码修改不生效
```bash
# 1. 停止所有服务
make docker-down
# 2. 重新构建镜像
make docker-build --no-cache
# 3. 重新启动
make docker-dev
```
---
## 🔍 常见问题
### Q1: 端口被占用怎么办?
**问题**:启动时报错 "port is already allocated"
**解决方案**
```bash
# 查看占用端口的进程
sudo lsof -i :8080
sudo lsof -i :5173
sudo lsof -i :3000
# 杀掉进程或修改 docker-compose.yml 中的端口映射
```
### Q2: 容器启动失败
**查看详细错误**
```bash
docker compose logs backend
docker compose logs frontend
```
**常见原因**
- 端口冲突
- 依赖服务未就绪
- 配置错误
**解决方案**
```bash
# 清理并重新启动
docker compose down -v
docker compose build --no-cache
docker compose up
```
### Q3: 热重载不工作
**后端**
```bash
# 确认 Air 是否运行
docker compose logs backend | grep "air"
# 检查文件挂载
docker compose exec backend ls -la /app
```
**前端**
```bash
# 确认 Vite 是否运行
docker compose logs frontend | grep "VITE"
# 重启前端
docker compose restart frontend
```
### Q4: 数据库连接失败
**检查**
```bash
# 数据库是否运行
docker compose ps postgres
# 健康检查
docker compose exec postgres pg_isready
```
**解决方案**
- 确保使用生产模式(`docker compose up`
- 等待数据库健康检查通过(约 10-20 秒)
- 检查 `DATABASE_URL` 环境变量
### Q5: 前端无法连接后端
**检查**
```bash
# 后端是否运行
curl http://localhost:8080/health
# 网络连接
docker network inspect ocdp-network
```
**解决方案**
- 确认后端服务运行正常
- 检查 `VITE_API_BASE_URL` 环境变量
- 检查浏览器控制台错误
---
## 📖 进一步学习
### 详细文档
- [Docker 服务架构](./DOCKER_SERVICES.md) - 完整的服务说明
- [开发指南](./docs/development/specification.md) - 开发规范
- [API 文档](./backend/docs/openapi.yaml) - OpenAPI 规范
- [部署指南](./docs/deployment/docker-guide.md) - 生产部署
### 项目结构
```
ocdp-go/
├── backend/ # Go 后端服务
├── frontend/ # React 前端应用
├── api/ # OpenAPI 规范
├── docs/ # 项目文档
├── docker-compose.yml # 生产模式
├── docker-compose.dev.yml # 开发模式
├── docker-compose.mock.yml # Mock 模式
└── Makefile # 便捷命令
```
### 架构说明
```
┌─────────────┐ ┌─────────────┐
│ Frontend │────▶│ Backend │
│ (React) │◀────│ (Go API) │
└─────────────┘ └─────────────┘
┌─────────────┐
│ PostgreSQL │
│ Database │
└─────────────┘
```
---
## 🎉 开始使用
现在你已经准备好开始使用 OCDP 了!
**推荐的开发流程**
1. ✅ 使用 `make docker-dev` 启动开发环境
2. ✅ 修改代码(自动热重载)
3. ✅ 使用浏览器测试功能
4. ✅ 使用 `make docker-test-backend` 测试后端 API
5. ✅ 使用 `make docker-prod` 进行完整测试
**需要帮助?**
- 查看 [DOCKER_SERVICES.md](./DOCKER_SERVICES.md) 了解详细配置
- 查看 [GitHub Issues](https://github.com/your-repo/issues) 报告问题
- 查看项目文档获取更多信息
Happy Coding! 🚀

457
README.md
View File

@ -1,336 +1,201 @@
# OCDP - Open Cloud Development Platform
# OCDP - Open Cloud Deployment Platform
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/badge/go-1.24+-00ADD8?logo=go)](https://go.dev/)
[![Node Version](https://img.shields.io/badge/node-20+-339933?logo=node.js)](https://nodejs.org/)
[![Docker](https://img.shields.io/badge/docker-20.10+-2496ED?logo=docker)](https://www.docker.com/)
开源云原生部署平台,支持从 Harbor或其他 OCI Registry拉取 Helm Charts 并一键部署到多个 Kubernetes 集群。
开源云原生开发平台,用于管理 Kubernetes 集群、OCI Registry 和 Helm Charts 部署。
## 功能特性
---
- **多集群管理** - 支持多个 kubeconfig管理多个 K8S 集群
- **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
- **多租户支持** - Workspace 隔离,管理员和普通用户角色
- **存储后端** - NFS/PV/hostPath 存储配置管理
- **Chart 引用** - 管理可用的 Helm Charts
- **Values 模板** - 版本控制、支持回滚的配置模板
- **一键部署** - 从 Harbor 拉取 Charts 部署到指定集群
- **实例管理** - 支持升级、回滚、卸载 Helm Release
- **状态监控** - 实时同步 Helm Release 状态
## ✨ 特性
## 技术栈
- 🎯 **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
- 📦 **Artifact 浏览** - 浏览和管理 Helm Charts、容器镜像
- 🚀 **一键部署** - 可视化部署 Helm Charts 到 Kubernetes 集群
- 🔍 **智能过滤** - 按 MediaType 过滤 artifactschart、image、other
- 🎨 **现代 UI** - 响应式设计,基于 React + TypeScript
- 🔐 **安全认证** - JWT 认证,加密存储敏感信息
- 🐳 **容器化** - 完整的 Docker 支持,多种运行模式
- 🔄 **热重载** - 开发模式支持代码热重载
| 层级 | 技术 |
|------|------|
| 后端 | Go 1.21+, Hexagonal Architecture |
| 前端 | React 18, TypeScript, Next.js, TailwindCSS |
| 数据库 | PostgreSQL |
| 网关 | Nginx |
---
## 快速开始
## 🚀 快速开始
### 前置要求
- Docker 20.10+
- Docker Compose 2.0+
- (可选) Make 工具
### 5分钟快速体验
### Docker Compose 启动(推荐)
```bash
# 1. 克隆项目
git clone <repository-url>
cd ocdp-go
# 1. 完全停止并清理现有容器
docker compose -f docker-compose.yml -f backend/docker-compose.yml down -v
# 2. 启动开发环境Mock 模式,无需数据库
make docker-dev
# 2. 启动所有服务PostgreSQL + Backend + Frontend + Nginx
ALLOWED_DEV_ORIGINS="http://10.6.80.114:3000" \
NEXT_PUBLIC_API_URL="http://10.6.80.114:8080/api/v1" \
BACKEND_PORT=8080 \
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d
# 3. 访问应用
# - 前端http://localhost:5173
# - 后端http://localhost:8080
# - 默认账号admin / admin123
# 3. 查看服务状态
docker ps
# 4. 访问
# 前端: http://10.6.80.114
# 后端: http://10.6.80.114:8080/api/v1
# 默认账号: admin / admin123
# 停止服务
docker compose -f docker-compose.yml -f backend/docker-compose.yml down
```
**详细指南**:查看 [快速开始指南](./QUICK_START.md)
### 开发环境
---
```bash
# 1. 确保 PostgreSQL 运行在 localhost:5432
# 启动 PostgreSQL 容器:
# docker run -d --name ocdp-postgres -e POSTGRES_DB=ocdp -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:15
## 📚 文档导航
# 2. 初始化数据库(首次运行)
# 方法一:手动执行 SQL
cat backend/scripts/init-db.sql | docker exec -i ocdp-postgres psql -U postgres -d ocdp
### 📖 核心文档(必读)
- 🚀 [快速开始](./QUICK_START.md) - 5分钟快速上手
- 📋 [使用指南](./USAGE_GUIDE.md) - 详细使用说明(推荐)
- 💡 [命令速查表](./COMMANDS_CHEATSHEET.md) - 常用命令快速参考
- 📚 [文档中心](./docs/README.md) - 完整文档索引
# 方法二:使用 Make需确保 PostgreSQL 容器名为 ocdp-postgres
make db-init
### 🔧 专业文档
- 📐 [开发规范](./docs/development/specification.md) - 代码规范和架构
- 🚢 [部署指南](./docs/deployment/docker-guide.md) - 生产环境部署
- 🔒 [安全实践](./docs/security/security-implementation.md) - 安全配置
- 🎨 [功能文档](./docs/features/) - 详细功能说明
# 3. 启动后端(需要设置环境变量)
cd backend && \
DATABASE_URL="postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable" \
JWT_SECRET="test-jwt-secret-key" \
ENCRYPTION_KEY="test-encryption-key-32-bytes-long" \
PORT=8081 \
ALLOWED_DEV_ORIGINS="10.6.80.114,localhost,127.0.0.1" \
go run cmd/api/main.go
### 🔗 其他资源
- 📋 [OpenAPI 规范](./backend/docs/openapi.yaml) - RESTful API 定义
- 📦 [历史文档](./docs/archive/) - 项目演进历史
# 4. 启动前端(需要 Node.js 20
source ~/.nvm/nvm.sh && nvm use 20
cd frontend && NEXT_PUBLIC_API_URL=http://10.6.80.114:8081/api/v1 npm run dev
---
# 5. 从外部访问
# 本机访问: http://localhost:3000
# 外部访问: http://10.6.80.114:3000
# 默认账号: admin / admin123
## 🏗️ 技术架构
# ===== 一键启动脚本 =====
# 启动所有服务(后台运行)
./start.sh
### 技术栈
**后端**
- Go 1.24+ (Hexagonal Architecture)
- PostgreSQL 16
- Redis 7
**前端**
- React 18
- TypeScript 5
- Vite 6
- TailwindCSS 3
**容器化**
- Docker
- Docker Compose
- Multi-stage builds
### 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend │
│ React + TypeScript + Vite │
└──────────────────────────┬──────────────────────────────────┘
│ HTTP/REST
┌──────────────────────────┼──────────────────────────────────┐
│ │ Backend API │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Input Adapters │ │
│ │ (REST/GraphQL) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Domain Services │ │
│ │ (Business Logic) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ┌──────────▼──────────┐ │
│ │ Output Adapters │ │
│ │ (Repos/Clients) │ │
│ └──────────┬──────────┘ │
└───────────────────────┼─┴────────────────────────────────┘
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ PG DB │ │ Redis │ │ OCI │
│ │ │ │ │ Registry│
└─────────┘ └─────────┘ └─────────┘
# 停止所有服务
./stop.sh
```
### 运行模式
### 生产环境Docker Compose
| 模式 | 特点 | 适用场景 | 命令 |
|------|------|----------|------|
| **开发模式** | Mock 数据,热重载 | 日常开发 | `make docker-dev` |
| **生产模式** | 真实数据库,完整功能 | 生产部署 | `make docker-prod` |
| **Mock 模式** | 独立测试单个服务 | 单元测试 | `make docker-test-backend` |
```bash
# 构建并启动所有服务
make run-2
---
# 停止服务
make clean-2
```
## 🛠️ 开发指南
## 配置
### 项目结构
### 环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| DATABASE_URL | postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable | 数据库连接串 |
| PORT | 8080 | 后端端口 |
| BACKEND_PORT | 8080 | Docker 映射端口 |
| JWT_SECRET | change-me-in-production | JWT 密钥 |
| ENCRYPTION_KEY | change-me-32-bytes-long-key-here | 加密密钥 |
| ALLOWED_DEV_ORIGINS | http://10.6.80.114:3000 | 允许的跨域来源(外部访问时需要配置)|
| NEXT_PUBLIC_API_URL | http://10.6.80.114:8080/api/v1 | 前端调用的API地址 |
### 配置文件
项目根目录 `.env` 文件包含默认配置:
- Kubernetes 集群配置
- Harbor 仓库信息
- NFS 存储配置
## 访问
| 服务 | 地址 |
|------|------|
| 前端 (Nginx) | http://10.6.80.114 |
| 后端 API | http://10.6.80.114:8080/api/v1 |
| API 文档 | http://10.6.80.114:8080/api/docs |
**默认账号**: `admin` / `admin123`
## 项目结构
```
ocdp-go/
├── backend/ # Go 后端服务
│ ├── cmd/api/ # 应用入口
│ ├── internal/ # 内部代码
│ │ ├── adapter/ # 适配器层
│ │ ├── domain/ # 领域层
│ │ └── bootstrap/ # 启动配置
── Dockerfile # 生产环境
│ ├── Dockerfile.dev # 开发环境
│ └── Dockerfile.mock # Mock 测试
├── frontend/ # React 前端应用
├── backend/ # Go 后端 (Hexagonal Architecture)
│ ├── cmd/api/ # 入口
│ ├── internal/
│ │ ├── adapter/ # 适配器层 (HTTP, Persistence)
│ │ ├── domain/ # 领域层 (Entity, Service, Repository)
│ │ └── bootstrap/ # 初始化和种子数据
── scripts/ # 脚本 (init-db.sql)
├── frontend/ # Next.js 前端
│ ├── src/
│ │ ├── core/ # 核心功能
│ │ ├── features/ # 功能模块
│ │ └── shared/ # 共享组件
── Dockerfile # 生产环境
│ ├── Dockerfile.dev # 开发环境
│ └── Dockerfile.mock # Mock 测试
── api/ # API 规范
│ └── openapi.yaml # OpenAPI 定义
├── docs/ # 项目文档
│ ├── features/ # 功能文档
│ ├── deployment/ # 部署文档
│ └── development/ # 开发文档
├── docker-compose.yml # 统一配置(使用 profiles
└── Makefile # 便捷命令
│ │ ├── app/ # 页面路由
│ │ ├── components/ # 组件
│ │ └── lib/ # 工具库 (API, types, auth)
── .env.local # 前端环境配置
├── infra/nginx/ # Nginx 配置
── docker-compose.yml # 主配置
├── backend/docker-compose.yml # 后端配置
── Makefile # 构建命令
```
### 常用命令
## 核心 API
| 模块 | 端点 | 说明 |
|------|------|------|
| 认证 | POST /api/v1/auth/login | 用户登录 |
| 用户 | GET/POST /api/v1/users | 用户管理 |
| Workspaces | GET/POST /api/v1/workspaces | 工作空间管理 |
| Clusters | GET/POST /api/v1/clusters | 集群管理 |
| Registries | GET/POST /api/v1/registries | 镜像仓库管理 |
| Storage | GET/POST /api/v1/storage-backends | 存储后端管理 |
| Chart References | GET/POST /api/v1/chart-references | Chart 引用管理 |
| Values Templates | GET/POST /api/v1/values-templates | 配置模板管理 |
| Instances | GET/POST/DELETE /api/v1/instances | 实例部署管理 |
## 权限模型
- **Admin** - 管理员,可管理所有资源和用户
- **User** - 普通用户,仅可访问所属 Workspace 的资源
## 开发命令
```bash
# Docker 服务(推荐)
make docker-dev # 启动开发环境
make docker-prod # 启动生产环境
make docker-test-backend # 测试后
make docker-test-frontend # 测试前端
make docker-logs # 查看日志
make docker-down # 停止服务
# 启动开发服务器
make dev # 同时启动前后端
make dev-backend # 仅后端
make dev-frontend # 仅前
# OpenAPI 工作流
make openapi-validate # 验证 API 规范
make openapi-gen # 生成代码
make openapi-docs # 生成文档
# 数据库操作
make db-init # 初始化数据库
make db-reset # 重置数据库
make db-shell # 打开数据库 shell
# 本地开发(不使用 Docker
make install # 安装依赖
make dev-local # 启动本地开发
make test # 运行测试
# Docker 构建
make build # 构建所有镜像
make build-backend # 构建后端镜像
make build-frontend # 构建前端镜像
# 日志和调试
make logs # 查看所有日志
make logs-backend # 后端日志
make stop # 停止开发服务器
```
### 开发工作流
## License
1. **启动开发环境**
```bash
make docker-dev
```
2. **修改代码**(自动热重载):
- 后端:编辑 `backend/` 下的 Go 文件
- 前端:编辑 `frontend/src/` 下的 React 组件
3. **查看日志**
```bash
make docker-logs
```
4. **测试功能**
- 前端http://localhost:5173
- 后端http://localhost:8080
5. **提交代码**
```bash
git add .
git commit -m "feat: add new feature"
git push
```
---
## 🧪 测试
### 后端测试
```bash
# 启动后端 Mock
make docker-test-backend-bg
# 测试健康检查
curl http://localhost:8080/health
# 测试登录
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
# 测试 API
curl http://localhost:8080/api/v1/registries
curl http://localhost:8080/api/v1/clusters
```
### 前端测试
```bash
# 启动前端 Mock
make docker-test-frontend-bg
# 访问前端
open http://localhost:3000
```
### 集成测试
```bash
# 启动完整环境
make docker-prod
# 运行测试套件
make test
```
---
## 📦 部署
### Docker Compose 部署(推荐)
```bash
# 1. 配置环境变量
export JWT_SECRET="your-production-secret"
export ENCRYPTION_KEY="your-32-byte-encryption-key"
# 2. 启动服务
docker compose up -d
# 3. 查看状态
docker compose ps
```
### Kubernetes 部署
查看 [Kubernetes 部署指南](./docs/deployment/kubernetes-guide.md)
---
## 🤝 贡献
欢迎贡献代码!请遵循以下步骤:
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
3. 提交更改 (`git commit -m 'feat: add amazing feature'`)
4. 推送分支 (`git push origin feature/amazing-feature`)
5. 创建 Pull Request
### 开发规范
- **代码风格**Go (gofmt)TypeScript (ESLint + Prettier)
- **提交规范**:遵循 [Conventional Commits](https://www.conventionalcommits.org/)
- **测试覆盖**:新功能必须包含测试
---
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
---
## 🙏 致谢
- [Go](https://go.dev/) - 后端开发语言
- [React](https://react.dev/) - 前端框架
- [Vite](https://vitejs.dev/) - 构建工具
- [Docker](https://www.docker.com/) - 容器化平台
- [Kubernetes](https://kubernetes.io/) - 容器编排
- [Harbor](https://goharbor.io/) - OCI Registry
---
## 📞 联系方式
- **项目主页**https://github.com/your-org/ocdp-go
- **问题反馈**https://github.com/your-org/ocdp-go/issues
- **文档网站**https://docs.ocdp.example.com
---
<div align="center">
<sub>Built with ❤️ by the OCDP Team</sub>
</div>
MIT

View File

@ -1,127 +0,0 @@
# 启动和更新后端服务指南
## 方式一:使用 Makefile推荐
### 启动完整服务(前端 + 后端 + Nginx
```bash
make run-2
```
这会:
1. 重新构建前端静态资源
2. 重新构建后端镜像
3. 启动 PostgreSQL、Backend 和 Nginx 服务
### 停止服务
```bash
make clean-2
```
---
## 方式二:只更新后端服务
### 1. 停止服务
```bash
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
```
### 2. 重新构建并启动后端
```bash
# 设置环境变量(必须!)
export COMPOSE_PROJECT_NAME=ocdp
export ADAPTER_MODE=production
export BACKEND_BUILD_CONTEXT=$(pwd)/backend
export BACKEND_BUILD_DOCKERFILE=$(pwd)/backend/Dockerfile
export BACKEND_MOCK_BUILD_DOCKERFILE=$(pwd)/backend/Dockerfile.mock
export INIT_DB_SQL_PATH=$(pwd)/backend/scripts/init-db.sql
# 重新构建后端镜像
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend build backend
# 启动服务
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d postgres backend nginx
```
### 3. 或者使用一行命令(强制重建)
```bash
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d --build --force-recreate backend
```
---
## 方式三:只重启后端容器(不重新构建)
```bash
# 重启后端容器
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend restart backend
# 或者先停止再启动
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend stop backend
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend start backend
```
---
## 方式四:查看日志
```bash
# 查看所有服务日志
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend logs -f
# 只查看后端日志
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend logs -f backend
```
---
## 方式五:开发模式(只启动数据库,本地运行后端)
```bash
# 只启动 PostgreSQL
docker compose -f backend/docker-compose.yml up -d postgres
# 然后在本地运行后端
cd backend
go run cmd/api/main.go
```
---
## 常用命令速查
```bash
# 查看运行中的服务
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend ps
# 查看后端容器状态
docker ps | grep ocdp-backend
# 进入后端容器
docker exec -it ocdp-backend sh
# 查看后端健康状态
curl http://localhost:8080/health
# 停止所有服务
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
# 停止并删除数据卷(清理数据)
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down -v
```
---
## 快速更新后端(推荐流程)
```bash
# 1. 停止服务
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend down
# 2. 重新构建并启动(使用 Makefile
make run-2
# 或者手动执行
docker compose -f docker-compose.yml -f backend/docker-compose.yml --profile backend up -d --build
```

View File

@ -1,355 +0,0 @@
# OCDP 使用指南
## 🎯 统一的 docker-compose.yml
现在所有配置都整合在一个文件中,使用 **profiles** 区分不同运行模式。
---
## 🚀 快速开始
### 方式 1: 使用 Make推荐
```bash
# 开发模式
make docker-dev
# 生产模式
make docker-prod
# 测试后端
make docker-test-backend
# 测试前端
make docker-test-frontend
```
### 方式 2: 使用 Docker Compose
```bash
# 开发模式
docker compose --profile dev up
# 生产模式
docker compose --profile production up
# Mock 测试模式(后端)
docker compose --profile mock up backend-mock
# Mock 测试模式(前端)
docker compose --profile mock up frontend-mock
```
---
## 📋 三种运行模式
### 1. 开发模式Dev Profile
**特点**
- ✅ 后端 Mock 模式(无需数据库)
- ✅ 热重载Air + Vite HMR
- ✅ 快速启动
**启动命令**
```bash
# 前台运行
docker compose --profile dev up
# 后台运行
docker compose --profile dev up -d
# 或使用 Make
make docker-dev
make docker-dev-bg
```
**访问地址**
- 前端http://localhost:5173
- 后端http://localhost:8080
**服务列表**
- `backend-dev` - 后端开发服务
- `frontend-dev` - 前端开发服务
### 2. 生产模式Production Profile
**特点**
- ✅ 真实数据库PostgreSQL + Redis
- ✅ 完整功能
- ✅ 生产环境配置
**启动命令**
```bash
# 后台运行
docker compose --profile production up -d
# 或使用 Make
make docker-prod
make docker-up
```
**访问地址**
- 前端http://localhost:3000
- 后端http://localhost:8080
- 数据库localhost:5432
**服务列表**
- `postgres` - PostgreSQL 数据库
- `redis` - Redis 缓存
- `backend-prod` - 后端生产服务
- `frontend-prod` - 前端生产服务
### 3. Mock 测试模式Mock Profile
**特点**
- ✅ 独立测试单个服务
- ✅ 无外部依赖
- ✅ 快速启动
**测试后端**
```bash
# 前台运行
docker compose --profile mock up backend-mock
# 后台运行
docker compose --profile mock up -d backend-mock
# 或使用 Make
make docker-test-backend
make docker-test-backend-bg
```
**测试前端**
```bash
# 前台运行
docker compose --profile mock up frontend-mock
# 后台运行
docker compose --profile mock up -d frontend-mock
# 或使用 Make
make docker-test-frontend
make docker-test-frontend-bg
```
---
## 🛠️ 常用操作
### 查看日志
```bash
# 查看所有日志
docker compose logs -f
# 查看特定服务日志
docker compose logs -f backend-dev
docker compose logs -f frontend-dev
docker compose logs -f backend-prod
docker compose logs -f backend-mock
# 或使用 Make
make docker-logs
make docker-logs-backend
make docker-logs-frontend
```
### 停止服务
```bash
# 停止所有服务
docker compose down
# 停止特定 profile 的服务
docker compose --profile dev down
docker compose --profile production down
docker compose --profile mock down
# 或使用 Make
make docker-down
```
### 重启服务
```bash
# 重启所有运行的服务
docker compose restart
# 重启特定服务
docker compose restart backend-dev
docker compose restart frontend-dev
# 或使用 Make
make docker-restart
make docker-restart-backend
make docker-restart-frontend
```
### 构建镜像
```bash
# 构建所有镜像
docker compose build
# 无缓存构建
docker compose build --no-cache
# 或使用 Make
make docker-build
make docker-build-no-cache
```
### 查看状态
```bash
# 查看运行的服务
docker compose ps
# 查看详细状态
make docker-status
```
---
## 🔧 开发工具
### 启动 pgAdmin 和 Swagger UI
```bash
# 需要先启动生产模式
docker compose --profile production --profile tools up -d
# 或使用 Make
make docker-tools
```
**访问地址**
- pgAdminhttp://localhost:5050
- Swagger UIhttp://localhost:8081
---
## 📊 服务对比表
| 特性 | 开发模式 | 生产模式 | Mock 模式 |
|------|---------|---------|----------|
| **Profile** | `dev` | `production` | `mock` |
| **后端服务** | `backend-dev` | `backend-prod` | `backend-mock` |
| **前端服务** | `frontend-dev` | `frontend-prod` | `frontend-mock` |
| **数据库** | ❌ Mock | ✅ PostgreSQL | ❌ Mock |
| **前端端口** | 5173 | 3000 | 3000 |
| **热重载** | ✅ | ❌ | ❌ |
| **启动时间** | ~15秒 | ~30秒 | ~5秒 |
---
## 💡 使用场景示例
### 场景 1: 日常开发
```bash
# 1. 启动开发环境
make docker-dev
# 2. 修改代码(自动热重载)
# 3. 查看日志
make docker-logs
# 4. 停止
make docker-down
```
### 场景 2: 测试后端 API
```bash
# 1. 启动后端 Mock
make docker-test-backend-bg
# 2. 测试 API
curl http://localhost:8080/health
curl http://localhost:8080/api/v1/registries
# 3. 停止
docker compose --profile mock down
```
### 场景 3: 生产环境部署
```bash
# 1. 配置环境变量
export JWT_SECRET="your-secret"
export ENCRYPTION_KEY="your-32-byte-key"
# 2. 启动生产环境
make docker-prod
# 3. 检查状态
make docker-status
# 4. 启动管理工具
make docker-tools
```
### 场景 4: 完全重置
```bash
# 1. 停止并删除所有容器和数据
docker compose down -v
# 2. 删除镜像
docker rmi $(docker images | grep ocdp | awk '{print $3}')
# 3. 重新构建
make docker-build
# 4. 重新启动
make docker-dev
```
---
## 🎓 环境变量
### 开发模式环境变量
后端自动使用:
- `ADAPTER_MODE=mock`
- `JWT_SECRET=dev-secret-key`
### 生产模式环境变量
可通过 `.env` 文件或导出环境变量设置:
```bash
export JWT_SECRET="your-production-secret"
export ENCRYPTION_KEY="your-production-encryption-key-32-bytes"
```
---
## 📚 相关文档
- [README.md](./README.md) - 项目概述
- [DOCKER_SERVICES.md](./DOCKER_SERVICES.md) - 详细架构说明
- [COMMANDS_CHEATSHEET.md](./COMMANDS_CHEATSHEET.md) - 命令速查表
- [QUICK_START.md](./QUICK_START.md) - 快速开始指南
---
## ✨ 优势
通过整合到单个 `docker-compose.yml` 文件:
-**更简洁**:只需维护一个配置文件
-**更清晰**:所有服务定义在同一处
-**更灵活**:通过 profiles 轻松切换模式
-**更易维护**:减少配置重复
-**向后兼容**Make 命令保持不变
---
<div align="center">
<sub>简化配置,提升效率!🚀</sub>
</div>

View File

@ -1,452 +0,0 @@
# Bootstrap 预注入数据说明
## 📋 概述
Bootstrap 功能在应用启动时自动预注入初始数据,帮助快速搭建开发/测试环境。
**配置文件**: `config/bootstrap.json`
---
## 🔧 预注入数据内容
### 1⃣ 用户 (Users)
预注入 **1 个管理员账户**
| 字段 | 值 | 说明 |
|------|-----|------|
| **username** | `admin` | 管理员用户名 |
| **password** | `admin123` | 初始密码(⚠️ 生产环境请修改) |
| **email** | `admin@example.com` | 邮箱地址 |
**用途**:
- 登录后台管理系统
- 测试用户认证功能
- 管理集群和 Registry
**密码加密**: 使用 bcrypt 加密存储
---
### 2⃣ Registry (OCI 镜像仓库)
预注入 **1 个 Harbor Registry**
| 字段 | 值 | 说明 |
|------|-----|------|
| **name** | `Harbor Production` | Registry 名称 |
| **url** | `https://harbor.example.com` | Registry 地址 |
| **description** | `Production Harbor Registry` | 描述 |
| **username** | `admin` | Registry 用户名 |
| **password** | `Harbor12345` | Registry 密码(加密存储) |
| **insecure** | `false` | 是否跳过 SSL 验证 |
**用途**:
- 浏览 Helm Chart 制品
- 拉取 OCI Artifacts
- 测试 Registry 连接
**密码加密**: 使用 AES 加密存储(基于 `ENCRYPTION_KEY` 环境变量)
---
### 3⃣ Kubernetes 集群 (Clusters)
预注入 **1 个测试集群**
| 字段 | 值 | 说明 |
|------|-----|------|
| **name** | `Test Cluster` | 集群名称 |
| **host** | `https://kubernetes.example.com:6443` | Kubernetes API Server 地址 |
| **description** | `Test Kubernetes Cluster` | 描述 |
| **caData** | `LS0tLS1CRUdJTi1D...` | CA 证书Base64 编码) |
| **certData** | `LS0tLS1CRUdJTi1D...` | 客户端证书Base64 编码) |
| **keyData** | `LS0tLS1CRUdJTi1S...` | 客户端密钥Base64 编码) |
**用途**:
- 部署 Helm Chart 应用
- 查看集群状态和资源
- 测试 Kubernetes 集成
**证书加密**: CA/Cert/Key 使用 AES 加密存储
---
## 🎯 Bootstrap 模式
### Mock 模式 (run-0)
- ✅ Bootstrap 启用
- ✅ 数据存储在内存中
- ✅ 重启后数据重置
### 真实模式 (run-1, run-2)
- ✅ Bootstrap 启用
- ✅ 数据存储在 PostgreSQL
- ✅ 重启后数据持久化
- ⚠️ **避免重复**: 如果数据已存在会跳过创建
---
## 📝 配置文件
### 完整配置示例 (`config/bootstrap.json`)
```json
{
"enabled": true,
"users": [
{
"username": "admin",
"password": "admin123",
"email": "admin@example.com"
}
],
"registries": [
{
"name": "Harbor Production",
"url": "https://harbor.example.com",
"description": "Production Harbor Registry",
"username": "admin",
"password": "Harbor12345",
"insecure": false
}
],
"clusters": [
{
"name": "Test Cluster",
"host": "https://kubernetes.example.com:6443",
"description": "Test Kubernetes Cluster",
"caData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t...",
"certData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t...",
"keyData": "LS0tLS1CRUdJTi1SU0EgUFJJVkFURSBLRVktLS0tLQ=="
}
]
}
```
---
## ⚙️ 自定义配置
### 方式 1: 修改配置文件
```bash
# 编辑配置
vim config/bootstrap.json
# 重启应用
make run-1
```
### 方式 2: 通过环境变量
```bash
export BOOTSTRAP_CONFIG_JSON='{
"enabled": true,
"users": [
{"username": "myuser", "password": "mypass", "email": "user@example.com"}
],
"registries": [],
"clusters": []
}'
make run-1
```
### 方式 3: 指定配置文件路径
```bash
export BOOTSTRAP_CONFIG_FILE=/path/to/custom-bootstrap.json
make run-1
```
---
## 🔒 安全建议
### ⚠️ 生产环境注意事项
1. **修改默认密码**
```json
{
"users": [
{
"username": "admin",
"password": "YourStrongPasswordHere" // ⚠️ 修改
}
]
}
```
2. **设置强加密密钥**
```bash
# 生成 32 字节随机密钥
export ENCRYPTION_KEY=$(openssl rand -base64 32)
```
3. **使用真实证书**
- 替换 `caData`, `certData`, `keyData` 为真实集群证书
- 确保证书有效期和权限正确
4. **禁用 Bootstrap可选**
```json
{
"enabled": false
}
```
5. **删除配置文件**
```bash
# 首次启动后删除(数据已导入)
rm config/bootstrap.json
```
---
## 🧪 测试验证
### 验证用户
```bash
# 登录测试
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "admin123"
}'
# 预期返回: {"token": "eyJhbGc..."}
```
### 验证 Registry
```bash
# 查看 Registry 列表
curl http://localhost:8080/api/v1/registries
# 预期返回:
# [
# {
# "id": "...",
# "name": "Harbor Production",
# "url": "https://harbor.example.com"
# }
# ]
```
### 验证 Cluster
```bash
# 查看集群列表
curl http://localhost:8080/api/v1/clusters
# 预期返回:
# [
# {
# "id": "...",
# "name": "Test Cluster",
# "host": "https://kubernetes.example.com:6443"
# }
# ]
```
---
## 📊 启动日志示例
### 成功的 Bootstrap 日志
```
🌱 Starting bootstrap seeding...
↳ Seeding 1 user(s)...
✓ User 'admin' created
↳ Seeding 1 registry(ies)...
✓ Registry 'Harbor Production' created (credentials encrypted)
↳ Seeding 1 cluster(s)...
✓ Cluster 'Test Cluster' created (credentials encrypted)
✅ Bootstrap seeding completed
```
### 数据已存在的日志
```
🌱 Starting bootstrap seeding...
↳ Seeding 1 user(s)...
⊙ User 'admin' already exists, skipping
↳ Seeding 1 registry(ies)...
⊙ Registry 'Harbor Production' already exists, skipping
↳ Seeding 1 cluster(s)...
⊙ Cluster 'Test Cluster' already exists, skipping
✅ Bootstrap seeding completed
```
---
## 🔄 重置数据
### 重置 Mock 模式数据
```bash
# Mock 数据存储在内存,重启即重置
make run-0
# Ctrl+C
make run-0
```
### 重置真实数据库数据
```bash
# 清理并重新创建
make clean-1
make run-1
```
---
## 📖 更多示例
### 添加多个用户
```json
{
"enabled": true,
"users": [
{
"username": "admin",
"password": "admin123",
"email": "admin@example.com"
},
{
"username": "developer",
"password": "dev123",
"email": "dev@example.com"
},
{
"username": "operator",
"password": "ops123",
"email": "ops@example.com"
}
]
}
```
### 添加多个 Registry
```json
{
"registries": [
{
"name": "Harbor Production",
"url": "https://harbor.example.com",
"username": "admin",
"password": "password1"
},
{
"name": "Docker Hub",
"url": "https://registry-1.docker.io",
"username": "myuser",
"password": "password2"
},
{
"name": "GitHub Container Registry",
"url": "https://ghcr.io",
"username": "github-user",
"password": "ghp_token"
}
]
}
```
### 添加多个集群
```json
{
"clusters": [
{
"name": "Dev Cluster",
"host": "https://dev-k8s.example.com:6443",
"description": "Development Environment",
"caData": "...",
"certData": "...",
"keyData": "..."
},
{
"name": "Staging Cluster",
"host": "https://staging-k8s.example.com:6443",
"description": "Staging Environment",
"caData": "...",
"certData": "...",
"keyData": "..."
},
{
"name": "Production Cluster",
"host": "https://prod-k8s.example.com:6443",
"description": "Production Environment",
"caData": "...",
"certData": "...",
"keyData": "..."
}
]
}
```
---
## 🛠️ 故障排查
### 问题 1: Bootstrap 不生效
**症状**: 启动后没有预注入数据
**检查**:
```bash
# 1. 检查配置文件是否存在
ls -la config/bootstrap.json
# 2. 检查 enabled 是否为 true
cat config/bootstrap.json | jq .enabled
# 3. 查看启动日志
# 应该看到 "Starting bootstrap seeding..."
```
### 问题 2: 密码不正确
**症状**: 无法使用预注入的用户登录
**原因**: 密码在配置文件中可能已修改
**解决**:
```bash
# 查看配置文件中的密码
cat config/bootstrap.json | jq '.users[0].password'
# 使用正确的密码测试
curl -X POST http://localhost:8080/api/v1/auth/login \
-d '{"username":"admin","password":"配置文件中的密码"}'
```
### 问题 3: 重复创建报错
**症状**: 日志显示 "duplicate key" 错误
**原因**: 数据库中已存在同名记录
**解决**:
- 正常现象Bootstrap 会自动跳过
- 如需重新创建,使用 `make clean-1` 清理数据库
---
## 📚 相关文档
- **配置示例**: `config/bootstrap.example.json`
- **代码实现**: `internal/bootstrap/seeder.go`
- **架构文档**: `docs/architecture.md` - Bootstrap 预注入章节
---
**最后更新**: 2025-11-10

View File

@ -1,324 +0,0 @@
# Code First API 开发指南
## 🎯 概述
本项目现已支持 **Code First** 方式开发 API
1. **后端**:使用 Swagger 注释从代码生成 OpenAPI 文档
2. **前端**:使用 Orval 从 OpenAPI 文档生成 TypeScript 客户端
---
## 🛠️ 工具链
### 后端
- **swaggo/swag**: 从 Go 代码注释生成 OpenAPI/Swagger 文档
- 文档https://github.com/swaggo/swag
### 前端
- **Orval**: 从 OpenAPI 规范生成 TypeScript API 客户端
- 文档https://orval.dev/
---
## 📝 开发流程
### 1. 后端:添加 Swagger 注释
#### 主程序注释 (cmd/api/main.go)
```go
// @title OCDP Backend API
// @version 1.0
// @description OCDP (Open Cloud Development Platform) Backend API
//
// @host localhost:8080
// @BasePath /api/v1
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
package main
```
#### Handler 方法注释示例
```go
// ListArtifacts 列出 repository 中的所有 artifacts
// @Summary 列出 Repository 中的所有 Artifacts
// @Description 列出指定 Repository 中的所有 Artifact支持按类型过滤
// @Tags Artifacts
// @Accept json
// @Produce json
// @Param registry_id path string true "Registry ID"
// @Param repository_name path string true "Repository Name (URL encoded)"
// @Param media_type query string false "过滤类型 (all, chart, image, other)" default(all)
// @Success 200 {array} dto.TagResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts [get]
func (h *ArtifactHandler) ListArtifacts(w http.ResponseWriter, r *http.Request) {
// ...
}
```
### 2. 生成 OpenAPI 文档
```bash
# 在 backend 目录下运行
cd backend
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
# 生成的文件在 docs/swagger/ 目录:
# - swagger.json
# - swagger.yaml
# - docs.go
```
### 3. 前端:重新生成 API 客户端
```bash
# 在 frontend 目录下运行
cd frontend
npm run openapi-gen
# 或者
orval
```
生成的文件在 `src/api/generated-orval/` 目录。
---
## 🔄 完整工作流
### 添加新 API 的步骤
#### 1. 后端开发
```bash
# 1.1 创建 DTO
# internal/adapter/input/http/dto/new_feature_dto.go
# 1.2 创建 Handler 并添加 Swagger 注释
# internal/adapter/input/http/rest/new_feature_handler.go
# 1.3 注册路由
# cmd/api/main.go
# 1.4 生成 OpenAPI 文档
cd backend
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
# 1.5 复制到主文档 (可选)
cp docs/swagger/swagger.yaml docs/openapi.yaml
```
#### 2. 前端开发
```bash
# 2.1 重新生成 API 客户端
cd frontend
npm run openapi-gen
# 2.2 使用生成的客户端
import { listArtifacts } from '@/api/generated-orval/api';
const data = await listArtifacts({
registryId: 'xxx',
repositoryName: 'charts/nginx'
});
```
---
## 📋 Swagger 注释速查
### 常用标签
| 标签 | 说明 | 示例 |
|------|------|------|
| `@Summary` | 简短描述 | `@Summary 列出所有用户` |
| `@Description` | 详细描述 | `@Description 返回系统中的所有用户列表` |
| `@Tags` | API 分组 | `@Tags Users` |
| `@Accept` | 接受的格式 | `@Accept json` |
| `@Produce` | 返回的格式 | `@Produce json` |
| `@Param` | 参数 | `@Param id path string true "User ID"` |
| `@Success` | 成功响应 | `@Success 200 {object} dto.UserResponse` |
| `@Failure` | 失败响应 | `@Failure 404 {object} dto.ErrorResponse` |
| `@Router` | 路由定义 | `@Router /users/{id} [get]` |
| `@Security` | 安全认证 | `@Security BearerAuth` |
### 参数类型
```go
// 路径参数 (path)
@Param id path string true "User ID"
// 查询参数 (query)
@Param page query int false "Page number" default(1)
@Param size query int false "Page size" default(20)
// 请求体 (body)
@Param request body dto.CreateUserRequest true "User data"
// Header 参数 (header)
@Param X-Request-ID header string false "Request ID"
```
### 响应定义
```go
// 单个对象
@Success 200 {object} dto.UserResponse
// 数组
@Success 200 {array} dto.UserResponse
// 自定义响应
@Success 200 {object} map[string]interface{}
// 多个可能的响应
@Success 200 {object} dto.UserResponse "Success"
@Success 201 {object} dto.UserResponse "Created"
@Failure 400 {object} dto.ErrorResponse "Bad Request"
@Failure 404 {object} dto.ErrorResponse "Not Found"
@Failure 500 {object} dto.ErrorResponse "Internal Error"
```
---
## 🎯 最佳实践
### 1. 命名规范
| 层级 | 风格 | 示例 |
|------|------|------|
| **路径变量** | snake_case | `{registry_id}`, `{repository_name}` |
| **查询参数** | snake_case | `?media_type=chart` |
| **JSON 字段** | camelCase | `repositoryName`, `mediaType` |
| **Go 结构体** | PascalCase | `RepositoryName`, `MediaType` |
### 2. DTO 定义示例
```go
// ArtifactResponse Artifact 响应
type ArtifactResponse struct {
RepositoryName string `json:"repositoryName"` // Go: PascalCase, JSON: camelCase
Tag string `json:"tag"`
Type string `json:"type"`
Size int64 `json:"size"`
CreatedAt string `json:"createdAt"`
}
```
### 3. 完整的 Handler 示例
```go
// GetArtifact 获取 artifact 详情
// @Summary 获取 Artifact 详情
// @Description 获取指定 Artifact 的详细信息
// @Tags Artifacts
// @Accept json
// @Produce json
// @Param registry_id path string true "Registry ID"
// @Param repository_name path string true "Repository Name (URL encoded)"
// @Param reference path string true "Artifact Reference (tag or digest)"
// @Success 200 {object} dto.ArtifactResponse
// @Failure 404 {object} dto.ErrorResponse "Artifact not found"
// @Failure 500 {object} dto.ErrorResponse "Internal server error"
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference} [get]
func (h *ArtifactHandler) GetArtifact(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
registryID := vars["registry_id"]
repositoryName := vars["repository_name"]
reference := vars["reference"]
artifact, err := h.artifactService.GetArtifact(r.Context(), registryID, repositoryName, reference)
if err != nil {
respondError(w, http.StatusNotFound, "Artifact not found", err.Error())
return
}
response := &dto.ArtifactResponse{
RepositoryName: artifact.Repository,
Tag: artifact.Tag,
Digest: artifact.Digest,
Type: string(artifact.Type),
Size: artifact.Size,
CreatedAt: artifact.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
respondJSON(w, http.StatusOK, response)
}
```
---
## 🔧 故障排除
### 问题 1: swag 命令未找到
```bash
# 解决方案:安装 swag
go install github.com/swaggo/swag/cmd/swag@latest
# 确保 $GOPATH/bin 在 PATH 中
export PATH=$PATH:$(go env GOPATH)/bin
```
### 问题 2: 生成的文档不完整
```bash
# 确保使用了正确的参数
swag init -g cmd/api/main.go -o docs/swagger --parseDependency --parseInternal
# 检查注释格式是否正确
# 注释必须紧邻函数定义,中间不能有空行
```
### 问题 3: 前端客户端生成失败
```bash
# 检查 OpenAPI 文档是否有效
cd frontend
npx @apidevtools/swagger-cli validate ../backend/docs/openapi.yaml
# 清理并重新生成
rm -rf src/api/generated-orval
npm run openapi-gen
```
---
## 📚 参考资源
- **Swaggo 文档**: https://github.com/swaggo/swag
- **Swaggo 声明式注释**: https://github.com/swaggo/swag#declarative-comments-format
- **Orval 文档**: https://orval.dev/
- **OpenAPI 规范**: https://swagger.io/specification/
---
## ✅ 当前状态
### 已完成
- [x] 安装 swag 工具
- [x] 添加主程序 Swagger 注释
- [x] 为 Artifact Handler 添加完整注释
- [x] 生成 OpenAPI 文档
- [x] 前端配置 Orval
- [x] 重新生成前端客户端
### 待完成 (可选)
- [ ] 为其他 Handler 添加 Swagger 注释
- [ ] Auth Handler
- [ ] Cluster Handler
- [ ] Registry Handler
- [ ] Instance Handler
- [ ] Monitoring Handler
- [ ] 添加更详细的错误响应定义
- [ ] 添加请求/响应示例
- [ ] 配置 CI/CD 自动生成文档
---
## 🎉 总结
现在项目支持 Code First 开发流程:
1. **后端开发者**:写代码 + 注释 → 生成 OpenAPI
2. **前端开发者**:使用 OpenAPI → 生成 TypeScript 客户端
3. **文档自动同步**:代码即文档,保证一致性
这确保了 API 文档始终与代码保持同步!✨

View File

@ -1,223 +0,0 @@
# 开发环境快速指南
## 📋 前置要求
- Go 1.21+
- Docker & Docker Compose
- Air热加载工具: `go install github.com/cosmtrek/air@latest`
## 🎯 设计理念
本项目使用 **Docker Compose Profile** 机制,通过单一的 `docker-compose.yml` 文件支持三种运行模式,避免配置文件重复。
## 🚀 三种运行模式
### 模式 0: Mock 模式(最快)
纯本地运行,无需任何外部依赖,所有服务都是 Mock。
```bash
make run-0
```
**特点:**
- ✅ 启动最快(秒启动)
- ✅ 无需 Docker
- ✅ 支持热加载
- ✅ 适合快速功能开发
**停止:** `Ctrl+C`
---
### 模式 1: 开发模式(推荐)
Docker 提供真实的 PostgreSQL后端在本地运行并支持热加载。
```bash
make run-1
```
**特点:**
- ✅ 真实的数据库
- ✅ 支持热加载
- ✅ 代码修改实时生效
- ✅ 适合日常开发
**停止:** `Ctrl+C` 停止后端(数据库容器继续运行)
**清理:** `make clean-1` 清理数据库和临时文件
---
### 模式 2: 生产模式
所有服务完全容器化,模拟生产环境。
```bash
make run-2
```
**特点:**
- ✅ 完全容器化
- ✅ 接近生产环境
- ✅ 后台运行
- ❌ 无热加载
**查看日志:**
```bash
docker compose --profile backend logs -f
```
**停止:**
```bash
docker compose --profile backend down
```
**清理:** `make clean-2` 清理所有容器和构建产物
---
## 🧹 清理命令
```bash
# 清理模式 1 的产物(依赖容器 + 临时文件)
make clean-1
# 清理模式 2 的产物(所有容器 + 构建产物)
make clean-2
```
---
## 📊 常用工作流
### 日常开发
```bash
# 启动开发环境
make run-1
# ... 编码、测试 ...
# 代码修改会自动重新编译
# 下班停止Ctrl+C
# 第二天继续(依赖容器还在运行,直接启动)
make run-1
```
### 重置数据库
```bash
# 清理并重新开始
make clean-1
make run-1
```
### 测试生产部署
```bash
# 启动生产模式
make run-2
# 查看日志
docker compose -f docker-compose.prod.yml logs -f
# 测试完成后清理
make clean-2
```
---
## 🔧 环境变量配置
复制并修改环境变量配置:
```bash
cp env.example .env
```
主要配置项:
- `ADAPTER_MODE`: 设置为 `mock` 启用 Mock 模式(模式 0
- `POSTGRES_*`: 数据库配置(模式 1、2
- `JWT_SECRET`: JWT 密钥(生产环境必须修改)
- `ENCRYPTION_KEY`: 加密密钥(必须 32 字节)
---
## 📂 文件说明
| 文件 | 用途 |
|------|------|
| `Makefile` | 开发命令入口5个核心命令 |
| `.air.toml` | 热加载配置 |
| `docker-compose.yml` | 统一配置文件(使用 profile 区分模式) |
| `Dockerfile` | 生产镜像构建配置 |
| `DEVELOPMENT.md` | 本文档 |
### Docker Compose Profile 说明
- **无 profile**: 只启动 postgres用于模式 1
- **--profile backend**: 启动 postgres + backend用于模式 2
- **--profile mock**: 启动 backend-mock独立的 mock 模式容器)
---
## ❓ 常见问题
### Q: 模式 1 启动失败,提示端口被占用?
A: 检查是否有其他 PostgreSQL 实例在运行:
```bash
docker ps | grep postgres
# 或者修改 .env 中的 POSTGRES_PORT
```
### Q: 如何查看依赖服务状态?
A:
```bash
docker compose ps # 模式 1只有 postgres
docker compose --profile backend ps # 模式 2postgres + backend
```
### Q: 模式 1 和模式 2 的数据库数据会互相影响吗?
A: 会共享同一个数据库容器和数据卷 `ocdp-postgres-data`
- 模式 1: 使用本地 air 连接到 postgres 容器
- 模式 2: 使用 backend 容器连接到 postgres 容器
- 数据是持久化的,切换模式不会丢失数据
### Q: 如何完全清理所有 Docker 资源?
A:
```bash
make clean-1
make clean-2
docker system prune -a --volumes
```
---
## 💡 提示
- **首次使用**: 推荐从 `make run-0` 开始,快速验证环境
- **日常开发**: 使用 `make run-1`,享受热加载和真实数据库
- **部署前测试**: 使用 `make run-2`,确保容器化部署没问题
- **数据持久化**: 模式 1 和 2 的数据库数据都会持久化,`Ctrl+C` 不会丢失数据
- **完全重置**: 使用 `clean-*` 命令会删除数据卷,数据会丢失
---
## 🎯 快速参考
```bash
make # 显示帮助
make run-0 # Mock 模式
make run-1 # 开发模式
make run-2 # 生产模式
make clean-1 # 清理开发环境
make clean-2 # 清理生产环境
```

View File

@ -1,133 +0,0 @@
# 快速参考卡片
## 🚀 五个核心命令
```bash
make run-0 # Mock 模式(秒启动,无需 Docker
make run-1 # 开发模式Docker 依赖 + 本地热加载)
make run-2 # 生产模式(全部容器化)
make clean-1 # 清理开发环境
make clean-2 # 清理生产环境
```
---
## 📋 模式对比
| 特性 | run-0 | run-1 | run-2 |
|------|-------|-------|-------|
| **后端位置** | 本地 | 本地 | 容器 |
| **数据库** | Mock | 容器 | 容器 |
| **热加载** | ✅ | ✅ | ❌ |
| **启动速度** | ⚡ 秒启 | 🔥 5秒 | 🐳 30秒 |
| **适用场景** | 快速开发 | 日常开发 | 部署测试 |
| **依赖 Docker** | ❌ | ✅ | ✅ |
---
## 🎯 使用场景
### 场景 1: 快速功能开发
```bash
make run-0
# 修改代码,自动重新编译
# Ctrl+C 停止
```
### 场景 2: 日常开发(推荐)
```bash
make run-1
# 修改代码,自动重新编译
# Ctrl+C 停止后端(数据库继续运行)
# 第二天继续
make run-1 # 数据库还在,直接启动
# 重置数据库
make clean-1
make run-1
```
### 场景 3: 部署前测试
```bash
make run-2
# 查看日志
docker compose --profile backend logs -f
# 测试完成
make clean-2
```
---
## 🔧 Docker Compose 直接使用
### 启动依赖run-1 等价)
```bash
docker compose up -d postgres
go run cmd/api/main.go
```
### 启动完整环境run-2 等价)
```bash
docker compose --profile backend up -d
```
### 查看状态
```bash
docker compose ps # run-1
docker compose --profile backend ps # run-2
```
### 停止
```bash
docker compose down # run-1
docker compose --profile backend down # run-2
```
---
## 📂 关键文件
| 文件 | 说明 |
|------|------|
| `Makefile` | 5个核心命令 |
| `docker-compose.yml` | 使用 profile 的统一配置 |
| `.air.toml` | 热加载配置 |
| `env.example` | 环境变量模板 |
---
## 🐳 Docker Compose Profile 原理
```yaml
# docker-compose.yml
services:
postgres:
# 默认启动(无需 profile
backend:
profiles: [backend] # 需要 --profile backend
```
**使用方式**:
```bash
docker compose up -d # 只启动 postgres
docker compose --profile backend up -d # 启动 postgres + backend
```
---
## 💡 快速提示
- 首次使用运行: `go install github.com/cosmtrek/air@latest`
- 查看帮助: `make``make help`
- 完整文档: 查看 `DEVELOPMENT.md`
- 审查报告: 查看 `REVIEW.md`
---
## 🎯 一句话总结
**三种模式,五个命令,一个 Docker Compose 文件。**

View File

@ -1,343 +1,89 @@
# OCDP Backend
基于 Go 的 Kubernetes Helm Chart 管理服务后端,提供完整的制品浏览和应用部署能力。
Go 后端服务,提供 Kubernetes Helm Chart 部署能力。
## ✨ 特性
- 🎪 **Helm Chart 管理** - 完整的 Helm 生命周期支持
- 📦 **多 Registry 支持** - Harbor、Docker Hub、GHCR 等
- 🔍 **Artifact 浏览** - 自动识别类型、大小、metadata
- 🚀 **OCI 标准兼容** - 使用 ORAS Go SDK v2
- 🖥️ **多集群支持** - 管理多个 Kubernetes 集群
- 🔐 **认证支持** - JWT + 密码加密
- 📊 **实时状态** - Helm Release 状态监控
- 🏗️ **六边形架构** - 清晰的分层设计,易于测试和扩展
- 🔄 **双模式支持** - Mock 模式(开发调试)+ 默认模式(真实 PostgreSQL
## 🚀 快速开始
### 方式 1: Mock 模式(最快)
适合快速功能开发和 API 测试,无需数据库。
```bash
# 安装 Air首次
go install github.com/air-verse/air@latest
# 启动 Mock 模式
make dev-mock
```
### 方式 2: 本地 Backend + Docker 数据库(推荐日常开发)
支持数据持久化和热重载。
```bash
# 启动数据库
docker compose up -d postgres
# 启动 Backend
make dev
```
### 方式 3: 完全容器化(生产部署)
适合生产环境和 CI/CD。
```bash
# 启动完整服务
make prod
# 或
docker compose --profile backend up -d
```
### 本地运行
```bash
# Mock 模式(快速测试)
make run-mock
# 或
export ADAPTER_MODE=mock
go run cmd/api/main.go
# Production 模式(需要数据库)
make run-prod
```
### 验证服务
```bash
# 健康检查
curl http://localhost:8080/health
# 查看 API
curl http://localhost:8080/api/v1/registries | jq
# 访问 Swagger UI (交互式 API 文档)
open http://localhost:8080/api/docs
```
## 📚 文档
| 文档 | 说明 |
|------|------|
| [快速开始](QUICK-START.md) | **快速开始指南** - 3分钟上手 ⭐ |
| [命令速查表](COMMANDS.md) | **Make 命令参考** - 所有命令详解 ⭐ |
| [部署指南](DEPLOYMENT-GUIDE.md) | **完整部署指南** - Mock 和默认模式 |
| [架构文档](docs/architecture.md) | 六边形架构、目录结构、开发指南 |
| [OpenAPI 规范](docs/openapi.yaml) | **OpenAPI 3.0 规范** - 标准 API 定义 |
| [Swagger UI](http://localhost:8080/api/docs) | **交互式 API 文档** - 在线测试 API 🚀 |
| [API 与测试](docs/api-and-test.md) | REST API 参考文档 + 测试指南 |
### 🎯 API 文档使用指南
**方式 1: Swagger UI (推荐)**
启动服务后访问:[http://localhost:8080/api/docs](http://localhost:8080/api/docs)
特性:
- 📖 交互式文档 - 所有 API 可视化展示
- 🔧 在线测试 - 直接在浏览器中测试 API
- 🔐 认证支持 - 支持 JWT Token 认证
- 📦 Schema 查看 - 查看所有请求/响应模型
**方式 2: OpenAPI 规范文件**
```bash
# 查看规范文件
cat docs/openapi.yaml
# 使用 OpenAPI 工具生成客户端
openapi-generator-cli generate -i docs/openapi.yaml -g go -o ./client
# 在线验证
curl http://localhost:8080/api/docs/openapi.yaml
```
**方式 3: Markdown 文档**
查看 [docs/api-and-test.md](docs/api-and-test.md) - 完整的 API 参考文档
## 🏗️ 架构概览
采用**六边形架构**Hexagonal Architecture实现清晰的分层和依赖倒置
```
┌─────────────────────────────────────────────────────────────┐
│ Input Adapters │
│ (HTTP REST API) │
└──────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Domain Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Entities │ │ Services │ │ Interfaces │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Output Adapters │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Database │ │ OCI Client │ │ Helm Client │ │
│ │ (Mock/Prod) │ │ (Mock/ORAS) │ │ (Mock/Real) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
详见 [架构文档](docs/architecture.md)
## 🎯 核心 API
| 分类 | 端点 | 说明 |
|------|------|------|
| **认证** | `POST /api/v1/auth/login` | 用户登录 |
| **集群** | `GET /api/v1/clusters` | 列出集群 |
| | `POST /api/v1/clusters` | 创建集群 |
| **Registry** | `GET /api/v1/registries` | 列出 Registry |
| | `POST /api/v1/registries` | 创建 Registry |
| **Artifact** | `GET /api/v1/registries/{id}/repositories` | 列出仓库 |
| | `GET /api/v1/registries/{id}/repositories/{repo}/artifacts` | 列出制品 |
| **实例** | `POST /api/v1/clusters/{id}/instances` | 安装应用 |
| | `GET /api/v1/clusters/{id}/instances` | 列出实例 |
| | `PUT /api/v1/clusters/{id}/instances/{instanceId}` | 升级应用 |
| | `DELETE /api/v1/clusters/{id}/instances/{instanceId}` | 卸载应用 |
| | `GET /api/v1/clusters/{id}/instances/{instanceId}/entries` | 查看实例入口 |
| **监控** | `GET /api/v1/monitoring/summary` | 监控摘要 |
完整 API 文档: [docs/api.md](docs/api.md)
## 🔧 开发
### 环境要求
## 技术栈
- Go 1.21+
- PostgreSQL 15+ (生产模式)
- Docker & Docker Compose (可选)
- gorilla/mux (HTTP 路由)
- ORAS Go SDK v2 (OCI 操作)
- Helm SDK (Helm 操作)
- Kubernetes client-go
- PostgreSQL
### 常用命令
## 启动
```bash
# 查看所有命令
make help
# Mock 模式(无需数据库,无需外部服务)
ADAPTER_MODE=mock go run cmd/api/main.go
# 开发
make dev # 开发模式(热重载)
make build # 构建
make run-mock # Mock 模式运行
make run-prod # Production 模式运行
# 生产模式(需要 PostgreSQL + K8s/Harbor 连接验证)
# 启动 PostgreSQL
docker compose up -d postgres
# Docker Compose
make mock # Mock 模式
make prod # 生产模式
make logs # 查看日志
make status # 查看状态
make stop # 停止服务
# 启动后端(生产模式)
cd backend
export DATABASE_URL="postgres://postgres:postgres@localhost:5432/ocdp?sslmode=disable"
export JWT_SECRET="your-jwt-secret"
export ENCRYPTION_KEY="your-32-byte-encryption-key"
export PORT=8081
export ADAPTER_MODE=production
export KUBECONFIG=/home/ivanwu/.kube/config # 或你的 kubeconfig 路径
# 数据库
make db-up # 启动数据库
make db-psql # 连接数据库
make db-backup # 备份数据库
make pgadmin # 启动 pgAdmin
# Harbor 凭证(可选,用于验证 Registry 连接)
export HARBOR_URL="https://harbor.bwgdi.com"
export HARBOR_USERNAME="your-harbor-user"
export HARBOR_PASSWORD="your-harbor-password"
# NFS 配置(可选)
export NFS_SERVER="10.6.80.11"
export NFS_SHARE="/volume1/NFS"
go run cmd/api/main.go
```
### 项目结构
## 环境变量说明
| 变量 | 必需 | 说明 |
|------|------|------|
| DATABASE_URL | 是 | PostgreSQL 连接字符串 |
| JWT_SECRET | 是 | JWT 签名密钥 |
| ENCRYPTION_KEY | 是 | 32位加密密钥 |
| PORT | 否 | 服务端口,默认 8080 |
| ADAPTER_MODE | 否 | `mock``production`,默认 production |
| KUBECONFIG | 生产模式 | Kubernetes kubeconfig 文件路径 |
| HARBOR_URL | 否 | Harbor URL用于 Registry 验证 |
| HARBOR_USERNAME | 否 | Harbor 用户名 |
| HARBOR_PASSWORD | 否 | Harbor 密码 |
## 连接验证
在生产模式下,创建 Cluster 或 Registry 时会自动验证连接:
- **Cluster**: 尝试使用提供的凭证连接 K8s API Server
- **Registry**: 尝试使用提供的凭证登录 Harbor
如果验证失败,创建会返回错误信息。
## API 访问
- API: http://localhost:8081/api/v1
- Health: http://localhost:8081/health
- Swagger: http://localhost:8081/api/docs
## 项目结构
```
backend/
├── cmd/api/ # 程序入口
├── cmd/api/ # 入口
├── internal/
│ ├── domain/ # 🎯 领域层(核心)
│ │ ├── entity/ # 实体
│ │ ├── service/ # 业务逻辑
│ │ └── repository/ # 接口定义
│ ├── domain/ # 领域层
│ │ ├── entity/ # 实体
│ │ ├── service/ # 业务逻辑
│ │ └── repository/ # 接口
│ ├── adapter/
│ │ ├── input/http/ # 📥 REST API
│ │ └── output/ # 📤 数据库、OCI、Helm
── bootstrap/ # Bootstrap 预注入
│ └── pkg/ # 🔧 工具包
├── docs/ # 📚 文档
├── config/ # ⚙️ 配置
└── scripts/ # 🛠️ 脚本
```
详见 [架构文档](docs/architecture.md)
## 🔐 安全配置
### 环境变量
```bash
# 必需配置
ADAPTER_MODE=production
JWT_SECRET=your-jwt-secret
ENCRYPTION_KEY=your-32-character-encryption-key
DATABASE_URL=postgresql://user:pass@host:5432/ocdp
# 生成安全密钥
openssl rand -base64 32
```
### Bootstrap 预注入
`config/bootstrap.json` 中配置初始数据:
```json
{
"enabled": true,
"users": [
{"username": "admin", "password": "admin123", "email": "admin@example.com"}
],
"registries": [
{"name": "harbor", "url": "https://harbor.example.com", "username": "admin", "password": "secret"}
],
"clusters": [
{"name": "prod", "host": "https://k8s.example.com:6443", "caData": "...", "certData": "...", "keyData": "..."}
]
}
```
详见 [架构文档 - Bootstrap 预注入](docs/architecture.md#bootstrap-预注入)
## 🌐 服务访问
| 服务 | 地址 | 说明 |
|------|------|------|
| Backend API | http://localhost:8080/api/v1 | REST API |
| Health Check | http://localhost:8080/health | 健康检查 |
| PostgreSQL | localhost:5432 | 数据库 |
| pgAdmin | http://localhost:5050 | 数据库管理 |
## 🐛 故障排查
### 常见问题
**端口被占用**:
```bash
# 修改 .env 中的 BACKEND_PORT
BACKEND_PORT=8081
```
**数据库连接失败**:
```bash
# 检查数据库状态
docker compose ps postgres
docker compose logs postgres
```
**完全重置**:
```bash
# 停止并删除所有数据
docker compose down -v
docker compose --profile production up -d
```
更多问题参见 [部署文档 - 故障排查](docs/deployment.md#故障排查)
## 📊 技术栈
| 组件 | 技术 |
|------|------|
| **语言** | Go 1.21+ |
| **Web 框架** | gorilla/mux |
| **OCI 客户端** | ORAS Go SDK v2 |
| **Helm 集成** | Helm SDK |
| **Kubernetes** | client-go |
| **数据库** | PostgreSQL 15+ |
| **容器化** | Docker, Docker Compose |
| **热重载** | Air |
## 🔗 相关资源
### 规范和文档
- [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec)
- [OCI Image Specification](https://github.com/opencontainers/image-spec)
- [Helm Documentation](https://helm.sh/docs/)
### 使用的库
- [Gorilla Mux](https://github.com/gorilla/mux) - HTTP 路由
- [ORAS Go SDK](https://oras.land/docs/category/go-library) - OCI Registry 操作
- [Helm SDK](https://helm.sh/docs/topics/advanced/) - Helm 操作
- [Kubernetes Client-Go](https://github.com/kubernetes/client-go) - K8s API 客户端
## 📝 待办事项
- [ ] 添加单元测试和集成测试
- [ ] 实现 Rate Limiting
- [ ] 添加审计日志
- [ ] 实现 Webhook 通知
- [ ] 支持更多 OCI Registry 类型
- [ ] 添加 Metrics 和 Tracing
## 📄 License
MIT License
---
**Version**: 2.2.0
**Last Updated**: 2025-11-09
**Port**: 8080 (default)
│ │ ├── input/http/ # REST API
│ │ └── output/ # 数据库、OCI、Helm
── bootstrap/ # 启动配置
└── docs/ # OpenAPI 规范
```

View File

@ -1,283 +0,0 @@
# 项目实现审查报告
## ✅ 要求对照检查
### 1. 文档结构 ✅
#### 1.1 README.md ✅
- 位置: `/backend/README.md`
- 状态: ✅ 已存在
- 内容: 项目概述、快速开始、API文档、架构概览
#### 1.2 docs/ ✅
所有文档齐全:
##### 2.1 architecture.md ✅
- 位置: `/backend/docs/architecture.md`
- 状态: ✅ 已存在
- 内容完整包含:
- ✅ 2.1.1 **需求描述** (Requirement Description)
- 项目背景
- 核心需求
- 功能需求
- 非功能需求
- ✅ 2.1.2 **业务建模** (Business Modeling)
- 业务领域
- 核心实体
- 业务流程
- 用例场景
- ✅ 2.1.3 **技术建模** (Technical Modeling)
- ✅ 2.1.3.1 **六边形架构** (Hexagonal Architecture)
- 架构图
- 层次说明
- 依赖倒置原则
- ✅ 2.1.3.2 **技术选型** (Technology Selection)
- Go 语言
- Web 框架
- OCI/Helm 客户端
- 数据库选型
##### 2.2 api-and-test.md ✅
- 位置: `/backend/docs/api-and-test.md`
- 状态: ✅ 已存在
- 内容: REST API 文档、测试指南
##### 2.3 deployment.md ✅
- 位置: `/backend/docs/deployment.md`
- 状态: ✅ 已存在
- 内容: 部署指南、环境配置
---
### 2. 开发模式 ✅
#### 3.1 Mode 0: Hot Reload + Mock ✅
```bash
make run-0
```
- ✅ 热加载支持 (Air)
- ✅ Mock 所有依赖 (内存实现)
- ✅ 无需任何容器
- ✅ 使用环境变量 `ADAPTER_MODE=mock`
**实现方式**:
```makefile
run-0:
ADAPTER_MODE=mock air -c .air.toml
```
#### 3.2 Mode 1: Hot Reload + Real Deps in Container ✅
```bash
make run-1
```
- ✅ 热加载支持 (Air)
- ✅ 真实依赖 (PostgreSQL) 运行在容器中
- ✅ 后端代码在本地运行
- ✅ 使用 Docker Compose **无 profile** 启动 postgres
**实现方式**:
```makefile
run-1:
@docker compose up -d postgres
@sleep 5
ADAPTER_MODE= \
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
air -c .air.toml
```
#### 3.3 Mode 2: All in Container ✅
```bash
make run-2
```
- ✅ 所有服务容器化
- ✅ 无热加载(生产模式)
- ✅ 使用 Docker Compose **--profile backend**
**实现方式**:
```makefile
run-2:
@docker compose --profile backend up --build -d
```
---
### 3. Makefile 设计 ✅
#### 核心要求: 使用 Profile 共享单一 Docker Compose ✅
**✅ 已实现**: 使用 `docker-compose.yml` + Profile 机制
| 命令 | 功能 | Docker Compose 用法 |
|------|------|---------------------|
| `make run-0` | Mock 模式 | 不使用 Docker |
| `make run-1` | 开发模式 | `docker compose up -d postgres` (无 profile) |
| `make run-2` | 生产模式 | `docker compose --profile backend up -d` |
| `make clean-1` | 清理 run-1 | `docker compose down -v` |
| `make clean-2` | 清理 run-2 | `docker compose --profile backend down -v` |
#### Docker Compose Profile 配置 ✅
文件: `/backend/docker-compose.yml`
```yaml
services:
postgres:
# 默认启动(无需 profile
# 用于 run-1
backend:
profiles:
- backend # 需要 --profile backend 才启动
# 用于 run-2
backend-mock:
profiles:
- mock # 可选的 mock 容器模式
```
**优点**:
- ✅ 单一配置文件,避免重复
- ✅ Profile 清晰区分不同模式
- ✅ 符合 Docker Compose 最佳实践
---
## 📊 完整流程对照
### Process 1: Product Description ✅
#### 1.1 Requirement ✅
- 文档: `docs/architecture.md` - 2.1.1 需求描述
- 内容: 项目背景、核心需求、功能需求、非功能需求
#### 1.2 What API I Need ✅
- 文档: `docs/api-and-test.md`
- 内容: REST API 端点定义、请求/响应示例
#### 1.3 Generator Docs ✅
所有文档已完整生成:
-`docs/architecture.md` - 架构文档
- ✅ Business Modeling - 业务建模
- ✅ Technical Modeling - 技术建模
- ✅ Hexagonal Architecture - 六边形架构
- ✅ Technology Selection - 技术选型
-`docs/api-and-test.md` - API 文档
-`docs/deployment.md` - 部署文档
---
### Process 2: Development ✅
#### 2.1 Develop Mode 1, 2, 3 ✅
- ✅ Mode 0 (Mock): `make run-0`
- ✅ Mode 1 (Dev): `make run-1`
- ✅ Mode 2 (Prod): `make run-2`
---
### Process 3: Provide Makefile ✅
#### 3.1 Five Commands ✅
```bash
make run-0 # ✅ Hot reload + Mock (memory)
make run-1 # ✅ Hot reload + Real deps (container)
make run-2 # ✅ All in container
make clean-1 # ✅ Clean run-1 artifacts
make clean-2 # ✅ Clean run-2 artifacts
```
#### 3.2 Using Profile to Share Docker Compose ✅
- ✅ 使用单一的 `docker-compose.yml`
- ✅ 通过 `--profile backend` 区分 mode 1 和 mode 2
- ✅ Mode 1: `docker compose up -d postgres` (无 profile)
- ✅ Mode 2: `docker compose --profile backend up -d`
---
## 🎯 核心设计亮点
### 1. Docker Compose Profile 机制 ⭐⭐⭐⭐⭐
**传统做法** (❌ 不推荐):
```
docker-compose.dev.yml # 重复配置 postgres
docker-compose.prod.yml # 重复配置 postgres
```
**本项目做法** (✅ 推荐):
```yaml
# docker-compose.yml (单一文件)
services:
postgres: # 默认启动run-1 和 run-2 共享
backend:
profiles: [backend] # 只在 run-2 时启动
```
**优势**:
- ✅ 配置不重复 (DRY 原则)
- ✅ 维护简单 (只需修改一个文件)
- ✅ 数据共享 (run-1 和 run-2 使用同一个数据库)
### 2. 三种模式的清晰定位
| 模式 | 场景 | 启动速度 | 真实依赖 | 热加载 |
|------|------|---------|---------|--------|
| Mode 0 | 快速开发、单元测试 | ⚡ 秒启动 | ❌ Mock | ✅ |
| Mode 1 | 日常开发、集成测试 | 🔥 5秒 | ✅ | ✅ |
| Mode 2 | 部署前测试、生产环境 | 🐳 30秒 | ✅ | ❌ |
### 3. 命令语义清晰
```bash
run-0 → 0 依赖Mock all
run-1 → 1 部分容器化Deps in container
run-2 → 2 完全容器化All in container
clean-1 → 清理 run-1 的产物
clean-2 → 清理 run-2 的产物
```
---
## ✅ 最终验证
### 文档完整性
- [x] README.md
- [x] docs/architecture.md
- [x] 2.1.1 需求描述
- [x] 2.1.2 业务建模
- [x] 2.1.3 技术建模
- [x] 2.1.3.1 六边形架构
- [x] 2.1.3.2 技术选型
- [x] docs/api-and-test.md
- [x] docs/deployment.md
### 开发模式
- [x] Mode 0: Hot reload + Mock (memory)
- [x] Mode 1: Hot reload + Real deps (container)
- [x] Mode 2: All in container
### Makefile
- [x] 5 个核心命令
- [x] 使用 Profile 共享 Docker Compose
- [x] run-1 和 run-2 使用同一个 docker-compose.yml
---
## 🎉 结论
**所有要求均已满足!✅**
项目完全符合你的设计要求:
1. ✅ 完整的文档结构 (README + docs/)
2. ✅ 三种开发模式 (0/1/2)
3. ✅ 五个核心命令 (run-0/1/2, clean-1/2)
4. ✅ 使用 Profile 共享 Docker Compose关键设计
**特别亮点**:
- 使用 Docker Compose Profile 避免配置重复
- 命名清晰,语义明确 (run-0/1/2)
- 产物隔离 (clean-1 vs clean-2)
**可以直接使用!** 🚀

View File

@ -1,400 +0,0 @@
# 测试报告
**测试日期**: 2025-11-10
**测试内容**: Makefile 五个核心命令的功能测试
---
## ✅ 测试结果总览
| 命令 | 状态 | 说明 |
|------|------|------|
| `make run-0` | ✅ 通过 | Mock 模式正常工作 |
| `make run-1` | ✅ 通过 | Docker 依赖 + 热加载正常 |
| `make run-2` | ✅ 通过 | 完全容器化部署成功 |
| `make clean-1` | ✅ 通过 | 清理 run-1 产物完整 |
| `make clean-2` | ✅ 通过 | 清理 run-2 产物完整 |
---
## 📋 详细测试结果
### 1⃣ make run-0 (Mock 模式)
**测试命令**:
```bash
make run-0
```
**预期行为**:
- ✅ 使用 Mock 适配器(内存存储)
- ✅ 无需任何 Docker 容器
- ✅ 支持热加载Air
- ✅ 环境变量 `ADAPTER_MODE=mock`
**实际结果**:
```
✅ 服务启动成功
✅ Health API 正常: {"status":"healthy"}
✅ Registries API 返回 Mock 数据: 1 条记录
✅ Clusters API 返回 Mock 数据: 1 条记录
✅ Bootstrap 数据预注入成功
```
**日志输出**:
```
📝 Configuration: mode=mock, port=8080
✅ Output Adapters initialized (mode: mock)
🌱 Starting bootstrap seeding...
✓ User 'admin' created
✓ Registry 'Harbor Production' created
✓ Cluster 'Test Cluster' created
✅ Bootstrap seeding completed
🌐 Server starting on :8080
```
**结论**: ✅ **完全符合预期**
---
### 2⃣ make run-1 (Docker 依赖 + 热加载)
**测试命令**:
```bash
make run-1
```
**预期行为**:
- ✅ 启动 PostgreSQL 容器(使用 `docker compose up -d postgres`
- ✅ 后端代码在本地运行
- ✅ 支持热加载Air
- ✅ 连接真实数据库
- ✅ 环境变量 `ADAPTER_MODE=` (空,表示真实模式)
**实际结果**:
```
✅ PostgreSQL 容器启动: ocdp-postgres (healthy)
✅ 后端连接数据库成功
✅ 数据库 Schema 初始化成功
✅ Health API 正常
✅ Registries API 连接真实 PostgreSQL
✅ Clusters API 连接真实 PostgreSQL
```
**Docker 容器状态**:
```
NAME IMAGE STATUS
ocdp-postgres postgres:17-alpine Up (healthy)
```
**配置**:
```makefile
run-1:
@docker compose up -d postgres
ADAPTER_MODE= \
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/ocdp?sslmode=disable \
air -c .air.toml
```
**结论**: ✅ **完全符合预期**
---
### 3⃣ make run-2 (完全容器化)
**测试命令**:
```bash
make run-2
```
**预期行为**:
- ✅ 构建后端 Docker 镜像
- ✅ 启动 PostgreSQL + Backend 容器
- ✅ 使用 `--profile backend` 区分模式
- ✅ 后台运行
- ✅ 无热加载
**实际结果**:
```
✅ Docker 镜像构建成功: backend-backend
✅ 两个容器启动: ocdp-postgres + ocdp-backend
✅ 容器健康检查通过
✅ Health API 正常
✅ Registries API 返回真实数据: 1 条记录
✅ Clusters API 返回真实数据: 1 条记录
✅ 后端日志正常输出
```
**Docker 容器状态**:
```
NAME IMAGE STATUS
ocdp-backend backend-backend Up (healthy)
ocdp-postgres postgres:17-alpine Up (healthy)
```
**配置**:
```makefile
run-2:
@docker compose --profile backend up --build -d
```
**Docker Compose Profile 验证**:
- ✅ 使用单一的 `docker-compose.yml`
- ✅ PostgreSQL 默认启动(无 profile
- ✅ Backend 需要 `--profile backend` 才启动
- ✅ 符合设计要求
**结论**: ✅ **完全符合预期**
---
### 4⃣ make clean-1 (清理 run-1 产物)
**测试命令**:
```bash
make clean-1
```
**预期行为**:
- ✅ 停止并删除 PostgreSQL 容器
- ✅ 删除 Docker 数据卷
- ✅ 清空 `tmp/` 目录
- ✅ 删除 Docker 网络
**清理前状态**:
```
Docker 容器: ocdp-postgres (running)
tmp/ 目录: 包含 main, build-errors.log, test.txt
Docker 卷: ocdp-postgres-data
Docker 网络: ocdp-network
```
**清理后状态**:
```
✅ Docker 容器: 0 个
✅ tmp/ 目录: 已清空
✅ Docker 卷: ocdp-postgres-data 已删除
✅ Docker 网络: ocdp-network 已删除
```
**执行输出**:
```
🧹 Cleaning run-1 artifacts...
Container ocdp-postgres Stopping
Container ocdp-postgres Stopped
Container ocdp-postgres Removing
Container ocdp-postgres Removed
Volume ocdp-postgres-data Removing
Volume ocdp-postgres-data Removed
Network ocdp-network Removed
✅ run-1 cleaned
```
**配置**:
```makefile
clean-1:
@docker compose down -v
@rm -rf tmp/
```
**结论**: ✅ **清理完整,无残留**
---
### 5⃣ make clean-2 (清理 run-2 产物)
**测试命令**:
```bash
make clean-2
```
**预期行为**:
- ✅ 停止并删除 Backend + PostgreSQL 容器
- ✅ 删除 Docker 数据卷
- ✅ 清空 `bin/``dist/` 目录
- ✅ 删除 Docker 网络
**清理前状态**:
```
Docker 容器: ocdp-backend (running), ocdp-postgres (running)
bin/ 目录: 存在
dist/ 目录: 包含 test.tar.gz
Docker 卷: ocdp-postgres-data
Docker 网络: ocdp-network
```
**清理后状态**:
```
✅ Docker 容器: 0 个
✅ bin/ 目录: 0 个文件
✅ dist/ 目录: 0 个文件
✅ Docker 卷: ocdp-postgres-data 已删除
✅ Docker 网络: ocdp-network 已删除
```
**执行输出**:
```
🧹 Cleaning run-2 artifacts...
Container ocdp-backend Stopping
Container ocdp-backend Stopped
Container ocdp-backend Removing
Container ocdp-backend Removed
Container ocdp-postgres Stopping
Container ocdp-postgres Stopped
Container ocdp-postgres Removing
Container ocdp-postgres Removed
Volume ocdp-postgres-data Removed
Network ocdp-network Removed
✅ run-2 cleaned
```
**配置**:
```makefile
clean-2:
@docker compose --profile backend down -v
@rm -rf bin/ dist/
```
**结论**: ✅ **清理完整,无残留**
---
## 🎯 核心设计验证
### Docker Compose Profile 机制 ✅
**验证内容**: 使用单一 `docker-compose.yml` + Profile 实现三种模式
**验证结果**:
1. **run-1**: `docker compose up -d postgres`
- ✅ 只启动 postgres 服务(无 profile
- ✅ backend 服务不启动(需要 profile
2. **run-2**: `docker compose --profile backend up -d`
- ✅ 启动 postgres + backend 两个服务
- ✅ 使用 `--profile backend` 激活 backend
3. **共享配置**:
- ✅ 两种模式使用同一个 `docker-compose.yml`
- ✅ postgres 配置不重复
- ✅ 符合 DRY 原则
**配置文件**:
```yaml
services:
postgres:
# 默认启动(无需 profile
backend:
profiles:
- backend # 需要 --profile backend
depends_on:
- postgres
```
**结论**: ✅ **设计正确,实现完美**
---
## 📊 API 功能测试
### Health Check API
```bash
curl http://localhost:8080/health
```
**结果**: ✅ 三种模式均正常
```json
{"status":"healthy"}
```
---
### Registries API
```bash
curl http://localhost:8080/api/v1/registries
```
**run-0 (Mock)**: ✅ 返回 1 条 Mock 数据
**run-1 (PostgreSQL)**: ✅ 返回真实数据
**run-2 (容器)**: ✅ 返回真实数据
---
### Clusters API
```bash
curl http://localhost:8080/api/v1/clusters
```
**run-0 (Mock)**: ✅ 返回 1 条 Mock 数据
**run-1 (PostgreSQL)**: ✅ 返回真实数据
**run-2 (容器)**: ✅ 返回真实数据
---
## 🎉 总结
### ✅ 所有测试通过
-**run-0**: Mock 模式工作正常
-**run-1**: Docker 依赖 + 热加载正常
-**run-2**: 完全容器化部署成功
-**clean-1**: 清理 run-1 产物完整
-**clean-2**: 清理 run-2 产物完整
### ✅ 核心设计验证
-**Docker Compose Profile** 机制正确
-**单一配置文件** 实现多模式
-**产物隔离** 清晰
-**命名语义** 明确
### 🎯 符合所有设计要求
1. ✅ 三种运行模式0/1/2
2. ✅ 五个核心命令
3. ✅ 使用 Profile 共享 Docker Compose
4. ✅ 清理命令分离且完整
---
## 💡 使用建议
### 日常开发流程
```bash
# 快速开发
make run-0
# 需要真实数据库
make run-1
# 测试部署
make run-2
# 清理环境
make clean-1 # 或 make clean-2
```
### 注意事项
1. **run-0****run-1** 是前台运行,`Ctrl+C` 停止
2. **run-2** 是后台运行,使用 `docker compose --profile backend down` 停止
3. **clean-1****clean-2** 会删除数据卷,数据会丢失
4. run-1 和 run-2 共享同一个 PostgreSQL 容器配置和数据
---
## 📝 测试环境
- **操作系统**: Linux 5.15.0-160-generic
- **Docker**: 已安装
- **Docker Compose**: 已安装
- **Go**: 1.24+
- **Air**: 已安装
---
**测试结论**: 🎉 **所有功能正常,可以投入使用!**

View File

@ -27,6 +27,7 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
"github.com/gorilla/mux"
@ -104,6 +105,20 @@ func main() {
repos.MetricsClient,
)
// Workspace Service
workspaceService := service.NewWorkspaceService(
repos.WorkspaceRepo,
repos.QuotaRepo,
repos.UserRepo,
)
// User Management Service
userManagementService := service.NewUserManagementService(
repos.UserRepo,
repos.WorkspaceRepo,
passwordHasher,
)
log.Println("✅ Domain Services initialized")
// ===== 6. 加载并执行 Bootstrap 预注入 =====
@ -128,6 +143,27 @@ func main() {
monitoringHandler := rest.NewMonitoringHandler(monitoringService)
swaggerHandler := rest.NewSwaggerHandler()
// Workspace Handler
workspaceHandler := rest.NewWorkspaceHandler(workspaceService, authService)
// User Management Handler (Admin only)
userManagementHandler := rest.NewUserManagementHandler(userManagementService, authService, workspaceService)
// User Handler
userHandler := rest.NewUserHandler(authService, workspaceService)
// Storage Handler
storageService := service.NewStorageService(repos.StorageRepo)
storageHandler := rest.NewStorageHandler(storageService)
// Chart Reference Handler
chartRefService := service.NewChartReferenceService(repos.ChartRefRepo, repos.RegistryRepo)
chartRefHandler := rest.NewChartReferenceHandler(chartRefService)
// Values Template Handler
valuesTemplateService := service.NewValuesTemplateService(repos.ValuesTemplateRepo, repos.ChartRefRepo)
valuesTemplateHandler := rest.NewValuesTemplateHandler(valuesTemplateService)
log.Println("✅ Input Adapters (REST handlers) initialized")
// ===== 8. 设置路由 =====
@ -139,6 +175,14 @@ func main() {
instanceHandler,
monitoringHandler,
swaggerHandler,
workspaceHandler,
userManagementHandler,
userHandler,
storageHandler,
chartRefHandler,
valuesTemplateHandler,
tokenGenerator,
config.AllowedOrigins,
)
// ===== 9. 启动服务器 =====
@ -161,21 +205,28 @@ func main() {
// Config 应用配置
type Config struct {
AdapterMode string
Port string
JWTSecret string
EncryptionKey string
DatabaseURL string
AdapterMode string
Port string
JWTSecret string
EncryptionKey string
DatabaseURL string
AllowedOrigins []string
}
// loadConfig 加载配置
func loadConfig() *Config {
allowedOrigins := getEnv("ALLOWED_DEV_ORIGINS", "")
var origins []string
if allowedOrigins != "" {
origins = strings.Split(allowedOrigins, ",")
}
return &Config{
AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式)
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"),
DatabaseURL: getEnv("DATABASE_URL", ""),
AdapterMode: getEnv("ADAPTER_MODE", ""), // 默认为空字符串(真实模式)
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
EncryptionKey: getEnv("ENCRYPTION_KEY", "default-encryption-key-change-in-production"),
DatabaseURL: getEnv("DATABASE_URL", ""),
AllowedOrigins: origins,
}
}
@ -197,12 +248,66 @@ func setupRouter(
instanceHandler *rest.InstanceHandler,
monitoringHandler *rest.MonitoringHandler,
swaggerHandler *rest.SwaggerHandler,
workspaceHandler *rest.WorkspaceHandler,
userManagementHandler *rest.UserManagementHandler,
userHandler *rest.UserHandler,
storageHandler *rest.StorageHandler,
chartRefHandler *rest.ChartReferenceHandler,
valuesTemplateHandler *rest.ValuesTemplateHandler,
tokenGenerator *jwt.JWTManager,
allowedOrigins []string,
) *mux.Router {
router := mux.NewRouter().StrictSlash(true)
// 全局中间件
router.Use(loggingMiddleware)
router.Use(corsMiddleware)
router.Use(corsMiddleware(allowedOrigins))
// 预检请求处理 - 必须放在路由注册之前
router.HandleFunc("/{path:.*}", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
// 非 OPTIONS 请求返回 404
http.NotFound(w, r)
}).Methods(http.MethodOptions)
// JWT 解析中间件 - 为所有需要认证的请求设置用户信息 header
jwtMiddleware := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 跳过认证路由
if r.URL.Path == "/api/v1/auth/login" ||
r.URL.Path == "/api/v1/auth/register" ||
r.URL.Path == "/api/v1/auth/refresh" {
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
token := strings.TrimPrefix(authHeader, "Bearer ")
userID, username, role, workspaceID, err := tokenGenerator.Verify(token)
if err == nil && userID != "" {
// 设置 header 供 handlers 使用
r.Header.Set("X-User-ID", userID)
r.Header.Set("X-Username", username)
r.Header.Set("X-User-Role", role)
r.Header.Set("X-Workspace-ID", workspaceID)
}
}
next.ServeHTTP(w, r)
})
}
// 健康检查
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
@ -220,12 +325,39 @@ func setupRouter(
// API v1
api := router.PathPrefix("/api/v1").Subrouter()
// 应用 CORS 和 JWT 中间件到所有 API 路由
api.Use(corsMiddleware(allowedOrigins))
api.Use(jwtMiddleware)
// ===== 认证路由 =====
api.HandleFunc("/auth/register", authHandler.Register)
api.HandleFunc("/auth/login", authHandler.Login)
api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
// ===== 用户账户路由 =====
api.HandleFunc("/users/me", userHandler.GetCurrentUser).Methods(http.MethodGet)
api.HandleFunc("/users/me/password", userHandler.ChangePassword).Methods(http.MethodPut)
api.HandleFunc("/users/me/workspace", userHandler.GetCurrentUserWorkspace).Methods(http.MethodGet)
// ===== 用户管理路由Admin =====
api.HandleFunc("/admin/users", userManagementHandler.CreateUser).Methods(http.MethodPost)
api.HandleFunc("/admin/users", userManagementHandler.ListUsers).Methods(http.MethodGet)
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.GetUser).Methods(http.MethodGet)
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.UpdateUser).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/active", userManagementHandler.SetUserActive).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/workspace", userManagementHandler.ChangeUserWorkspace).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}/password", userManagementHandler.ResetPassword).Methods(http.MethodPut)
api.HandleFunc("/admin/users/{user_id}", userManagementHandler.DeleteUser).Methods(http.MethodDelete)
// ===== Workspace 路由 =====
api.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost)
api.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.GetWorkspace).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.UpdateWorkspace).Methods(http.MethodPut)
api.HandleFunc("/workspaces/{workspace_id}", workspaceHandler.DeleteWorkspace).Methods(http.MethodDelete)
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.GetWorkspaceQuotas).Methods(http.MethodGet)
api.HandleFunc("/workspaces/{workspace_id}/quotas", workspaceHandler.SetWorkspaceQuotas).Methods(http.MethodPut)
// ===== 集群路由 =====
api.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
api.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
@ -242,11 +374,36 @@ func setupRouter(
api.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
api.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet)
// ===== Storage Backend 路由 =====
api.HandleFunc("/storage-backends", storageHandler.CreateStorage).Methods(http.MethodPost)
api.HandleFunc("/storage-backends", storageHandler.GetAllStorage).Methods(http.MethodGet)
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.GetStorage).Methods(http.MethodGet)
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.UpdateStorage).Methods(http.MethodPut)
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.DeleteStorage).Methods(http.MethodDelete)
// ===== Chart Reference 路由 =====
api.HandleFunc("/chart-references", chartRefHandler.CreateChartReference).Methods(http.MethodPost)
api.HandleFunc("/chart-references", chartRefHandler.GetAllChartReferences).Methods(http.MethodGet)
api.HandleFunc("/chart-references/{chart_reference_id}", chartRefHandler.GetChartReference).Methods(http.MethodGet)
api.HandleFunc("/chart-references/{chart_reference_id}", chartRefHandler.UpdateChartReference).Methods(http.MethodPut)
api.HandleFunc("/chart-references/{chart_reference_id}", chartRefHandler.DeleteChartReference).Methods(http.MethodDelete)
// ===== Values Template 路由 =====
api.HandleFunc("/values-templates", valuesTemplateHandler.CreateValuesTemplate).Methods(http.MethodPost)
api.HandleFunc("/values-templates", valuesTemplateHandler.GetAllValuesTemplates).Methods(http.MethodGet)
api.HandleFunc("/values-templates/{template_id}", valuesTemplateHandler.GetValuesTemplate).Methods(http.MethodGet)
api.HandleFunc("/values-templates/{template_id}", valuesTemplateHandler.UpdateValuesTemplate).Methods(http.MethodPut)
api.HandleFunc("/values-templates/{template_id}", valuesTemplateHandler.DeleteValuesTemplate).Methods(http.MethodDelete)
api.HandleFunc("/chart-references/{chart_reference_id}/values-templates", valuesTemplateHandler.GetValuesTemplatesByChartReference).Methods(http.MethodGet)
api.HandleFunc("/chart-references/{chart_reference_id}/values-templates/history", valuesTemplateHandler.GetValuesTemplateHistory).Methods(http.MethodGet)
api.HandleFunc("/chart-references/{chart_reference_id}/values-templates/rollback", valuesTemplateHandler.RollbackValuesTemplate).Methods(http.MethodPost)
// ===== Artifact 路由 =====
api.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts", artifactHandler.ListArtifacts).Methods(http.MethodGet)
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}", artifactHandler.GetArtifact).Methods(http.MethodGet)
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values", artifactHandler.GetArtifactValues).Methods(http.MethodGet)
// ===== Instance 路由 =====
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
@ -285,25 +442,54 @@ func loggingMiddleware(next http.Handler) http.Handler {
}
// corsMiddleware CORS 中间件
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 设置 CORS 头
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*"
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
func corsMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// 处理 OPTIONS 预检请求
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// 验证 origin 是否在允许列表中
if origin != "" && len(allowedOrigins) > 0 {
allowed := false
for _, ao := range allowedOrigins {
if ao == origin || ao == "*" {
allowed = true
break
}
}
if !allowed {
// Origin 不在允许列表中,拒绝请求
w.Header().Set("Access-Control-Allow-Origin", "")
w.WriteHeader(http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
// 如果没有配置 allowedOrigins默认允许所有
if len(allowedOrigins) == 0 {
if origin == "" {
origin = "*"
}
}
// 优先处理 OPTIONS 预检请求
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
// 设置 CORS 头
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
next.ServeHTTP(w, r)
})
}
}

View File

@ -0,0 +1,11 @@
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
fmt.Println(string(hash))
}

View File

@ -67,6 +67,13 @@ services:
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
KUBECONFIG: ${KUBECONFIG:-.kube/config}
HARBOR_URL: ${HARBOR_URL:-}
HARBOR_USERNAME: ${HARBOR_USERNAME:-}
HARBOR_PASSWORD: ${HARBOR_PASSWORD:-}
NFS_SERVER: ${NFS_SERVER:-}
NFS_SHARE: ${NFS_SHARE:-}
ALLOWED_DEV_ORIGINS: ${ALLOWED_DEV_ORIGINS:-}
ports:
- "${BACKEND_PORT:-8080}:8080"
volumes:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3047
backend/docs/docs.go Normal file

File diff suppressed because it is too large Load Diff

3027
backend/docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1975
backend/docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ require (
dario.cat/mergo v1.0.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
@ -46,6 +47,7 @@ require (
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
@ -93,10 +95,12 @@ require (
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/swaggo/swag v1.16.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect
@ -104,6 +108,7 @@ require (
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.9 // indirect

View File

@ -10,6 +10,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@ -20,6 +22,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@ -94,11 +98,18 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
@ -160,6 +171,7 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -175,6 +187,9 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@ -213,6 +228,7 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
@ -280,6 +296,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/swag v1.16.5 h1:nMf2fEV1TetMTJb4XzD0Lz7jFfKJmJKGTygEey8NSxM=
github.com/swaggo/swag v1.16.5/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
@ -351,6 +369,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
@ -365,16 +384,21 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
@ -399,15 +423,19 @@ google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3i
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k=

34
backend/hash.go Normal file
View File

@ -0,0 +1,34 @@
//go:build ignore
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"golang.org/x/crypto/argon2"
)
func main() {
password := "admin123"
memory := 64 * 1024
iterations := 3
parallelism := 2
saltLength := 16
keyLength := 32
salt := make([]byte, saltLength)
rand.Read(salt)
hash := argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(memory), uint8(parallelism), uint32(keyLength))
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, memory, iterations, parallelism, b64Salt, b64Hash)
fmt.Println(encodedHash)
}

View File

@ -42,3 +42,8 @@ type ValuesSchemaResponse struct {
Schema string `json:"schema"`
}
// ValuesResponse Values 响应
type ValuesResponse struct {
Values string `json:"values"`
}

View File

@ -0,0 +1,31 @@
package dto
// CreateChartReferenceRequest 创建 Chart 引用请求
type CreateChartReferenceRequest struct {
RegistryID string `json:"registry_id" binding:"required"`
Repository string `json:"repository" binding:"required"`
ChartName string `json:"chart_name" binding:"required"`
Description string `json:"description"`
}
// UpdateChartReferenceRequest 更新 Chart 引用请求
type UpdateChartReferenceRequest struct {
RegistryID string `json:"registry_id"`
Repository string `json:"repository"`
ChartName string `json:"chart_name"`
Description string `json:"description"`
IsEnabled *bool `json:"is_enabled"`
}
// ChartReferenceResponse Chart 引用响应
type ChartReferenceResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
RegistryID string `json:"registry_id"`
Repository string `json:"repository"`
ChartName string `json:"chart_name"`
Description string `json:"description"`
IsEnabled bool `json:"is_enabled"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}

View File

@ -2,30 +2,36 @@ package dto
// CreateClusterRequest 创建集群请求
type CreateClusterRequest struct {
Name string `json:"name" binding:"required"`
Host string `json:"host" binding:"required"`
CAData string `json:"caData"`
CADataAlt string `json:"ca_data"`
CertData string `json:"certData"`
CertDataAlt string `json:"cert_data"`
KeyData string `json:"keyData"`
KeyDataAlt string `json:"key_data"`
Token string `json:"token"`
Description string `json:"description"`
Name string `json:"name" binding:"required"`
Host string `json:"host" binding:"required"`
CAData string `json:"caData"`
CADataAlt string `json:"ca_data"`
CertData string `json:"certData"`
CertDataAlt string `json:"cert_data"`
KeyData string `json:"keyData"`
KeyDataAlt string `json:"key_data"`
Token string `json:"token"`
Description string `json:"description"`
IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
IsShared bool `json:"isShared"` // 是否为共享集群
}
// UpdateClusterRequest 更新集群请求
type UpdateClusterRequest struct {
Name string `json:"name"`
Host string `json:"host"`
CAData string `json:"caData"`
CADataAlt string `json:"ca_data"`
CertData string `json:"certData"`
CertDataAlt string `json:"cert_data"`
KeyData string `json:"keyData"`
KeyDataAlt string `json:"key_data"`
Token string `json:"token"`
Description string `json:"description"`
Name string `json:"name"`
Host string `json:"host"`
CAData string `json:"caData"`
CADataAlt string `json:"ca_data"`
CertData string `json:"certData"`
CertDataAlt string `json:"cert_data"`
KeyData string `json:"keyData"`
KeyDataAlt string `json:"key_data"`
Token string `json:"token"`
Description string `json:"description"`
IsolationMode string `json:"isolationMode"`
DefaultNamespace string `json:"defaultNamespace"`
IsShared *bool `json:"isShared"`
}
// Normalize 将多种命名风格的字段合并到统一字段
@ -56,10 +62,16 @@ func (r *UpdateClusterRequest) Normalize() {
// ClusterResponse 集群响应(敏感数据已脱敏)
type ClusterResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Description string `json:"description"`
ID string `json:"id"`
WorkspaceID string `json:"workspaceId,omitempty"`
OwnerID string `json:"ownerId,omitempty"`
Name string `json:"name"`
Host string `json:"host"`
Description string `json:"description"`
IsolationMode string `json:"isolationMode"` // 'namespace' | 'cluster'
DefaultNamespace string `json:"defaultNamespace"` // 默认 namespace 前缀
IsShared bool `json:"isShared"` // 是否为共享集群
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
HasCAData bool `json:"hasCaData"`
HasCertData bool `json:"hasCertData"`

View File

@ -1,6 +1,8 @@
package dto
import (
"time"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/pkg/crypto"
)
@ -8,42 +10,50 @@ import (
// ToRegistryResponse 转换 Registry 实体为响应 DTO脱敏
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
response := &RegistryResponse{
ID: registry.ID,
Name: registry.Name,
URL: registry.URL,
Description: registry.Description,
Username: registry.Username,
Insecure: registry.Insecure,
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
ID: registry.ID,
WorkspaceID: registry.WorkspaceID,
OwnerID: registry.OwnerID,
Name: registry.Name,
URL: registry.URL,
Description: registry.Description,
Username: registry.Username,
Insecure: registry.Insecure,
IsShared: registry.IsShared,
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
// 脱敏处理密码
if registry.Password != "" {
response.HasPassword = true
response.Password = crypto.MaskSensitiveData(registry.Password)
}
return response
}
// ToClusterResponse 转换 Cluster 实体为响应 DTO脱敏
func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
response := &ClusterResponse{
ID: cluster.ID,
Name: cluster.Name,
Host: cluster.Host,
Description: cluster.Description,
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
ID: cluster.ID,
WorkspaceID: cluster.WorkspaceID,
OwnerID: cluster.OwnerID,
Name: cluster.Name,
Host: cluster.Host,
Description: cluster.Description,
IsolationMode: string(cluster.IsolationMode),
DefaultNamespace: cluster.DefaultNamespace,
IsShared: cluster.IsShared,
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
// 设置认证配置状态标志
response.HasCAData = cluster.CAData != ""
response.HasCertData = cluster.CertData != ""
response.HasKeyData = cluster.KeyData != ""
response.HasToken = cluster.Token != ""
// 脱敏处理敏感数据(仅显示掩码)
if cluster.CAData != "" {
response.CAData = crypto.MaskSensitiveData(cluster.CAData)
@ -57,7 +67,86 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
if cluster.Token != "" {
response.Token = crypto.MaskSensitiveData(cluster.Token)
}
return response
}
// WorkspaceDTOFromEntity 转换 Workspace 实体为 DTO
func WorkspaceDTOFromEntity(workspace *entity.Workspace) *WorkspaceDTO {
return &WorkspaceDTO{
ID: workspace.ID,
Name: workspace.Name,
Description: workspace.Description,
CreatedBy: workspace.CreatedBy,
CreatedAt: workspace.CreatedAt,
UpdatedAt: workspace.UpdatedAt,
}
}
// WorkspaceDTOsFromEntities 批量转换
func WorkspaceDTOsFromEntities(workspaces []*entity.Workspace) []*WorkspaceDTO {
result := make([]*WorkspaceDTO, len(workspaces))
for i, w := range workspaces {
result[i] = WorkspaceDTOFromEntity(w)
}
return result
}
// QuotaDTOFromEntity 转换 Quota 实体为 DTO
func QuotaDTOFromEntity(quota *entity.WorkspaceQuota) *QuotaDTO {
return &QuotaDTO{
ID: quota.ID,
WorkspaceID: quota.WorkspaceID,
ResourceType: string(quota.ResourceType),
HardLimit: quota.HardLimit,
SoftLimit: quota.SoftLimit,
Used: quota.Used,
}
}
// QuotaDTOsFromEntities 批量转换
func QuotaDTOsFromEntities(quotas []*entity.WorkspaceQuota) []*QuotaDTO {
result := make([]*QuotaDTO, len(quotas))
for i, q := range quotas {
result[i] = QuotaDTOFromEntity(q)
}
return result
}
// UserDTOFromEntity 转换 User 实体为 DTO
func UserDTOFromEntity(user *entity.User, workspaceName string) *UserDTO {
return &UserDTO{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: string(user.Role),
WorkspaceID: user.WorkspaceID,
WorkspaceName: workspaceName,
IsActive: user.IsActive,
MustChangePassword: user.MustChangePassword,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
}
// UserDTOsFromEntities 批量转换
func UserDTOsFromEntities(users []*entity.User, workspaceNames map[string]string) []*UserDTO {
result := make([]*UserDTO, len(users))
for i, u := range users {
workspaceName := ""
if u.WorkspaceID != "" {
workspaceName = workspaceNames[u.WorkspaceID]
}
result[i] = UserDTOFromEntity(u, workspaceName)
}
return result
}
// TimeToString 转换时间
func TimeToString(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02T15:04:05Z07:00")
}

View File

@ -23,12 +23,15 @@ type UpdateRegistryRequest struct {
// RegistryResponse Registry 响应(敏感数据已脱敏)
type RegistryResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description"`
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
HasPassword bool `json:"hasPassword"` // 是否已设置密码
IsShared bool `json:"is_shared"`
Insecure bool `json:"insecure"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`

View File

@ -0,0 +1,73 @@
package dto
// CreateStorageRequest 创建存储后端请求
type CreateStorageRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"` // nfs, pv, hostPath
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
// NFS 配置
NFS NFSConfigDTO `json:"nfs,omitempty"`
// PV 配置
PV PVConfigDTO `json:"pv,omitempty"`
// HostPath 配置
HostPath HostPathConfigDTO `json:"hostPath,omitempty"`
}
// UpdateStorageRequest 更新存储后端请求
type UpdateStorageRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
// NFS 配置
NFS NFSConfigDTO `json:"nfs,omitempty"`
// PV 配置
PV PVConfigDTO `json:"pv,omitempty"`
// HostPath 配置
HostPath HostPathConfigDTO `json:"hostPath,omitempty"`
}
// NFSConfigDTO NFS 配置
type NFSConfigDTO struct {
Server string `json:"server"`
Path string `json:"path"`
}
// PVConfigDTO PV 配置
type PVConfigDTO struct {
StorageClassName string `json:"storageClassName"`
Capacity string `json:"capacity"`
AccessModes []string `json:"accessModes"`
}
// HostPathConfigDTO HostPath 配置
type HostPathConfigDTO struct {
Path string `json:"path"`
}
// StorageResponse 存储后端响应
type StorageResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Config StorageConfigDTO `json:"config"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// StorageConfigDTO 存储配置(脱敏后)
type StorageConfigDTO struct {
NFS *NFSConfigDTO `json:"nfs,omitempty"`
PV *PVConfigDTO `json:"pv,omitempty"`
HostPath *HostPathConfigDTO `json:"hostPath,omitempty"`
}

View File

@ -0,0 +1,78 @@
package dto
import "time"
// UserDTO 用户 DTO
type UserDTO struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
WorkspaceID string `json:"workspace_id,omitempty"`
WorkspaceName string `json:"workspace_name,omitempty"`
IsActive bool `json:"is_active"`
MustChangePassword bool `json:"must_change_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateUserRequest 创建用户请求Admin 操作)
type CreateUserRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required,min=6"`
Email string `json:"email"`
Role string `json:"role" validate:"required,oneof=admin user"`
WorkspaceID string `json:"workspace_id"`
}
// UpdateUserRequest 更新用户请求
type UpdateUserRequest struct {
Email string `json:"email"`
IsActive *bool `json:"is_active"`
}
// ChangeUserWorkspaceRequest 分配用户到 Workspace 请求
type ChangeUserWorkspaceRequest struct {
WorkspaceID string `json:"workspace_id" validate:"required"`
}
// ResetPasswordRequest 重置密码请求
type ResetPasswordRequest struct {
NewPassword string `json:"new_password" validate:"required,min=6"`
}
// ChangePasswordRequest 修改密码请求
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=6"`
}
// SetUserActiveRequest 启用/禁用用户请求
type SetUserActiveRequest struct {
IsActive bool `json:"is_active"`
}
// UserListResponse 用户列表响应
type UserListResponse struct {
Users []*UserDTO `json:"users"`
Total int `json:"total"`
}
// UserWithWorkspaceResponse 用户及其 Workspace 响应
type UserWithWorkspaceResponse struct {
User *UserDTO `json:"user"`
Workspace *WorkspaceDTO `json:"workspace,omitempty"`
}
// LoginResponse 登录响应
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
MustChangePassword bool `json:"must_change_password"`
}
// UserResponseWithDTO 用户响应包含完整DTO
type UserResponseWithDTO struct {
User *UserDTO `json:"user"`
}

View File

@ -0,0 +1,37 @@
package dto
// CreateValuesTemplateRequest 创建 Values 模板请求
type CreateValuesTemplateRequest struct {
ChartReferenceID string `json:"chart_reference_id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ValuesYAML string `json:"values_yaml" binding:"required"`
IsDefault bool `json:"is_default"`
}
// UpdateValuesTemplateRequest 更新 Values 模板请求
type UpdateValuesTemplateRequest struct {
Description string `json:"description"`
ValuesYAML string `json:"values_yaml"`
IsDefault *bool `json:"is_default"`
}
// ValuesTemplateResponse Values 模板响应
type ValuesTemplateResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
ChartReferenceID string `json:"chart_reference_id"`
Name string `json:"name"`
Description string `json:"description"`
ValuesYAML string `json:"values_yaml"`
Version int `json:"version"`
IsDefault bool `json:"is_default"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// RollbackValuesTemplateRequest 回滚请求
type RollbackValuesTemplateRequest struct {
TemplateID string `json:"template_id" binding:"required"`
}

View File

@ -0,0 +1,67 @@
package dto
import "time"
// WorkspaceDTO 工作空间 DTO
type WorkspaceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateWorkspaceRequest 创建工作空间请求
type CreateWorkspaceRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
}
// UpdateWorkspaceRequest 更新工作空间请求
type UpdateWorkspaceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
// QuotaDTO 配额 DTO
type QuotaDTO struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
ResourceType string `json:"resource_type"`
HardLimit float64 `json:"hard_limit"`
SoftLimit float64 `json:"soft_limit"`
Used float64 `json:"used"`
}
// SetQuotaRequest 设置配额请求
type SetQuotaRequest struct {
ResourceType string `json:"resource_type" validate:"required"`
HardLimit float64 `json:"hard_limit" validate:"required"`
SoftLimit float64 `json:"soft_limit"`
}
// SetQuotasRequest 批量设置配额请求
type SetQuotasRequest struct {
CPU *QuotaValue `json:"cpu"`
GPU *QuotaValue `json:"gpu"`
GPUMemory *QuotaValue `json:"gpu_memory"`
}
// QuotaValue 配额值
type QuotaValue struct {
HardLimit float64 `json:"hard_limit"`
SoftLimit float64 `json:"soft_limit"`
}
// WorkspaceResponse 工作空间响应
type WorkspaceResponse struct {
Workspace *WorkspaceDTO `json:"workspace"`
Quotas []*QuotaDTO `json:"quotas,omitempty"`
}
// WorkspaceListResponse 工作空间列表响应
type WorkspaceListResponse struct {
Workspaces []*WorkspaceDTO `json:"workspaces"`
Total int `json:"total"`
}

View File

@ -0,0 +1,283 @@
package middleware
import (
"context"
"net/http"
"strings"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// Context keys
type contextKey string
const (
ContextKeyUserID contextKey = "user_id"
ContextKeyUsername contextKey = "username"
ContextKeyUserRole contextKey = "user_role"
ContextKeyWorkspaceID contextKey = "workspace_id"
)
// UserClaims 用户声明(从 JWT 解析)
type UserClaims struct {
UserID string
Username string
Role entity.UserRole
WorkspaceID string
}
// WorkspaceMiddleware 工作空间中间件
// 从 JWT 获取用户角色和 workspace_id进行权限检查
func WorkspaceMiddleware(userRepo repository.UserRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从 Header 获取 Token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing authorization header", http.StatusUnauthorized)
return
}
// 解析 Bearer Token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
return
}
token := parts[1]
// 这里需要从 AuthService 获取验证方法
// 简化处理:假设 token 包含 user_id 和 username
// 实际实现需要调用 JWT 验证服务
// 从数据库获取用户信息
// 注意:这里需要通过 token 解析出 userID
// 实际实现应该在 AuthService 中完成
_ = userRepo
next.ServeHTTP(w, r)
})
}
}
// RequireWorkspace 强制要求 workspace 上下文
// 用于非 Admin 用户的资源操作
func RequireWorkspace(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
workspaceID := r.Header.Get("X-Workspace-ID")
userRole := r.Header.Get("X-User-Role")
// Admin 可以没有 workspace
if userRole == string(entity.RoleAdmin) {
next.ServeHTTP(w, r)
return
}
// 普通用户必须有 workspace
if workspaceID == "" {
http.Error(w, "Workspace context required", http.StatusForbidden)
return
}
// 将 workspace_id 放入 context
ctx := context.WithValue(r.Context(), ContextKeyWorkspaceID, workspaceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireAdmin 要求 Admin 角色
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole := r.Header.Get("X-User-Role")
if userRole != string(entity.RoleAdmin) {
http.Error(w, "Admin access required", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// GetUserClaims 从 Context 获取用户声明
func GetUserClaims(ctx context.Context) *UserClaims {
userID, _ := ctx.Value(ContextKeyUserID).(string)
username, _ := ctx.Value(ContextKeyUsername).(string)
roleStr, _ := ctx.Value(ContextKeyUserRole).(string)
workspaceID, _ := ctx.Value(ContextKeyWorkspaceID).(string)
return &UserClaims{
UserID: userID,
Username: username,
Role: entity.UserRole(roleStr),
WorkspaceID: workspaceID,
}
}
// GetWorkspaceID 从 Context 获取 workspace ID
func GetWorkspaceID(ctx context.Context) string {
workspaceID, _ := ctx.Value(ContextKeyWorkspaceID).(string)
return workspaceID
}
// GetUserID 从 Context 获取用户 ID
func GetUserID(ctx context.Context) string {
userID, _ := ctx.Value(ContextKeyUserID).(string)
return userID
}
// GetUserRole 从 Context 获取用户角色
func GetUserRole(ctx context.Context) entity.UserRole {
roleStr, _ := ctx.Value(ContextKeyUserRole).(string)
return entity.UserRole(roleStr)
}
// FilterByWorkspace 根据用户角色过滤资源
// Admin: 返回所有资源workspaceID 忽略)
// User: 仅返回属于自己 workspace 的资源
func FilterByWorkspace(workspaceID, userRole string) (filterWorkspaceID string, isAdmin bool) {
if userRole == string(entity.RoleAdmin) {
return "", true
}
return workspaceID, false
}
// AuthorizationService 授权服务
type AuthorizationService struct {
userRepo repository.UserRepository
}
// NewAuthorizationService 创建授权服务
func NewAuthorizationService(userRepo repository.UserRepository) *AuthorizationService {
return &AuthorizationService{
userRepo: userRepo,
}
}
// CheckResourceAccess 检查用户是否有权访问指定资源
func (s *AuthorizationService) CheckResourceAccess(ctx context.Context, userID, resourceWorkspaceID string) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
// Admin 可以访问所有资源
if user.Role == entity.RoleAdmin {
return nil
}
// 普通用户只能访问自己 workspace 的资源
if user.WorkspaceID != resourceWorkspaceID {
return entity.ErrPermissionDenied
}
return nil
}
// CanAccessWorkspace 检查用户是否可以访问指定 workspace
func (s *AuthorizationService) CanAccessWorkspace(ctx context.Context, userID, targetWorkspaceID string) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return err
}
// Admin 可以访问所有 workspace
if user.Role == entity.RoleAdmin {
return nil
}
// 普通用户只能访问自己的 workspace
if user.WorkspaceID != targetWorkspaceID {
return entity.ErrPermissionDenied
}
return nil
}
// GetAccessibleWorkspaces 获取用户可访问的 workspace 列表
func (s *AuthorizationService) GetAccessibleWorkspaces(ctx context.Context, userID string) ([]string, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, err
}
// Admin 可以访问所有 workspace
if user.Role == entity.RoleAdmin {
return nil, nil // nil 表示所有
}
// 普通用户只能访问自己的 workspace
if user.WorkspaceID != "" {
return []string{user.WorkspaceID}, nil
}
return []string{}, nil
}
// RequireRole 要求特定角色
func RequireRole(roles ...entity.UserRole) func(http.Handler) http.Handler {
roleSet := make(map[entity.UserRole]bool)
for _, r := range roles {
roleSet[r] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole := r.Header.Get("X-User-Role")
if !roleSet[entity.UserRole(userRole)] {
http.Error(w, "Insufficient permissions", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// WithUserClaims 将用户声明注入到 Context
func WithUserClaims(claims *UserClaims) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, ContextKeyUserID, claims.UserID)
ctx = context.WithValue(ctx, ContextKeyUsername, claims.Username)
ctx = context.WithValue(ctx, ContextKeyUserRole, string(claims.Role))
if claims.WorkspaceID != "" {
ctx = context.WithValue(ctx, ContextKeyWorkspaceID, claims.WorkspaceID)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// LoginRequired 要求登录
func LoginRequired(authService *service.AuthService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization required", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
return
}
token := parts[1]
userID, _, err := authService.VerifyAccessToken(r.Context(), token)
if err != nil {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ContextKeyUserID, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@ -191,3 +191,42 @@ func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http
respondJSON(w, http.StatusOK, response)
}
// GetArtifactValues 获取 Helm Chart 的 values.yaml
// @Summary 获取 Helm Chart Values
// @Description 获取 Helm Chart 的 values.yaml 文件内容 (仅支持 Chart 类型)
// @Tags Artifacts
// @Accept json
// @Produce json
// @Param registry_id path string true "Registry ID"
// @Param repository_name path string true "Repository Name (URL encoded)"
// @Param reference path string true "Artifact Reference (tag or digest)"
// @Success 200 {object} dto.ValuesResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values [get]
func (h *ArtifactHandler) GetArtifactValues(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
registryID := vars["registry_id"]
repositoryName := vars["repository_name"]
reference := vars["reference"]
values, err := h.artifactService.GetValues(r.Context(), registryID, repositoryName, reference)
if err != nil {
switch {
case errors.Is(err, entity.ErrRegistryNotFound),
errors.Is(err, entity.ErrRepositoryNotFound),
errors.Is(err, entity.ErrArtifactNotFound),
errors.Is(err, entity.ErrValuesNotFound):
respondError(w, http.StatusNotFound, "Values not found", err.Error())
default:
respondError(w, http.StatusInternalServerError, "Failed to get values", err.Error())
}
return
}
response := &dto.ValuesResponse{
Values: values,
}
respondJSON(w, http.StatusOK, response)
}

View File

@ -0,0 +1,229 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// ChartReferenceHandler Chart Reference Handler
type ChartReferenceHandler struct {
chartRefService *service.ChartReferenceService
}
// NewChartReferenceHandler 创建 Chart Reference Handler
func NewChartReferenceHandler(chartRefService *service.ChartReferenceService) *ChartReferenceHandler {
return &ChartReferenceHandler{
chartRefService: chartRefService,
}
}
// CreateChartReference 创建 Chart 引用
// @Summary 创建 Chart 引用
// @Description 新增 Chart 引用配置
// @Tags Chart References
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateChartReferenceRequest true "Chart 引用信息"
// @Success 201 {object} dto.ChartReferenceResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /chart-references [post]
func (h *ChartReferenceHandler) CreateChartReference(w http.ResponseWriter, r *http.Request) {
var req dto.CreateChartReferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// 获取用户信息
workspaceID := r.Header.Get("X-Workspace-ID")
chartRef, err := h.chartRefService.Create(
r.Context(),
workspaceID,
req.RegistryID,
req.Repository,
req.ChartName,
req.Description,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to create chart reference", err.Error())
return
}
response := toChartReferenceResponse(chartRef)
respondJSON(w, http.StatusCreated, response)
}
// GetChartReference 获取 Chart 引用详情
// @Summary 获取 Chart 引用
// @Tags Chart References
// @Produce json
// @Security BearerAuth
// @Param chart_reference_id path string true "Chart Reference ID"
// @Success 200 {object} dto.ChartReferenceResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /chart-references/{chart_reference_id} [get]
func (h *ChartReferenceHandler) GetChartReference(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
chartRefID := vars["chart_reference_id"]
chartRef, err := h.chartRefService.GetByID(r.Context(), chartRefID)
if err != nil {
respondError(w, http.StatusNotFound, "Chart reference not found", err.Error())
return
}
response := toChartReferenceResponse(chartRef)
respondJSON(w, http.StatusOK, response)
}
// GetAllChartReferences 获取所有 Chart 引用
// @Summary 列出所有 Chart 引用
// @Tags Chart References
// @Produce json
// @Security BearerAuth
// @Success 200 {array} dto.ChartReferenceResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /chart-references [get]
func (h *ChartReferenceHandler) GetAllChartReferences(w http.ResponseWriter, r *http.Request) {
workspaceID := r.Header.Get("X-Workspace-ID")
role := r.Header.Get("X-User-Role")
var chartRefs []*dto.ChartReferenceResponse
// Admin 可以看到所有,其他用户只看自己 workspace
if role == "admin" {
allChartRefs, err := h.chartRefService.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list chart references", err.Error())
return
}
for _, cr := range allChartRefs {
chartRefs = append(chartRefs, toChartReferenceResponse(cr))
}
} else if workspaceID != "" {
workspaceChartRefs, err := h.chartRefService.GetByWorkspace(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list chart references", err.Error())
return
}
for _, cr := range workspaceChartRefs {
chartRefs = append(chartRefs, toChartReferenceResponse(cr))
}
}
respondJSON(w, http.StatusOK, chartRefs)
}
// UpdateChartReference 更新 Chart 引用
// @Summary 更新 Chart 引用
// @Tags Chart References
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param chart_reference_id path string true "Chart Reference ID"
// @Param request body dto.UpdateChartReferenceRequest true "更新内容"
// @Success 200 {object} dto.ChartReferenceResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /chart-references/{chart_reference_id} [put]
func (h *ChartReferenceHandler) UpdateChartReference(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
chartRefID := vars["chart_reference_id"]
var req dto.UpdateChartReferenceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
isEnabled := true
if req.IsEnabled != nil {
isEnabled = *req.IsEnabled
}
chartRef, err := h.chartRefService.Update(
r.Context(),
chartRefID,
req.RegistryID,
req.Repository,
req.ChartName,
req.Description,
isEnabled,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to update chart reference", err.Error())
return
}
response := toChartReferenceResponse(chartRef)
respondJSON(w, http.StatusOK, response)
}
// DeleteChartReference 删除 Chart 引用
// @Summary 删除 Chart 引用
// @Tags Chart References
// @Produce json
// @Security BearerAuth
// @Param chart_reference_id path string true "Chart Reference ID"
// @Success 204 {string} string "No Content"
// @Failure 404 {object} dto.ErrorResponse
// @Router /chart-references/{chart_reference_id} [delete]
func (h *ChartReferenceHandler) DeleteChartReference(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
chartRefID := vars["chart_reference_id"]
if err := h.chartRefService.Delete(r.Context(), chartRefID); err != nil {
respondError(w, http.StatusNotFound, "Failed to delete chart reference", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// GetChartReferencesByRegistry 获取 Registry 的所有 Chart 引用
// @Summary 获取 Registry 的 Chart 引用
// @Tags Chart References
// @Produce json
// @Security BearerAuth
// @Param registry_id path string true "Registry ID"
// @Success 200 {array} dto.ChartReferenceResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/chart-references [get]
func (h *ChartReferenceHandler) GetChartReferencesByRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
registryID := vars["registry_id"]
chartRefs, err := h.chartRefService.GetByRegistry(r.Context(), registryID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list chart references", err.Error())
return
}
responses := make([]*dto.ChartReferenceResponse, 0, len(chartRefs))
for _, cr := range chartRefs {
responses = append(responses, toChartReferenceResponse(cr))
}
respondJSON(w, http.StatusOK, responses)
}
// toChartReferenceResponse 转换为响应 DTO
func toChartReferenceResponse(chartRef *entity.ChartReference) *dto.ChartReferenceResponse {
return &dto.ChartReferenceResponse{
ID: chartRef.ID,
WorkspaceID: chartRef.WorkspaceID,
RegistryID: chartRef.RegistryID,
Repository: chartRef.Repository,
ChartName: chartRef.ChartName,
Description: chartRef.Description,
IsEnabled: chartRef.IsEnabled,
CreatedAt: chartRef.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: chartRef.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

View File

@ -40,13 +40,20 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
req.Normalize()
req.Normalize()
// 创建实体
cluster := entity.NewCluster(req.Name, req.Host)
cluster := entity.NewCluster("", "", req.Name, req.Host)
cluster.Description = req.Description
if req.CertData != "" && req.KeyData != "" {
// 设置认证信息
hasKubeconfig := req.CAData != "" && (len(req.CAData) > 100 && (req.CAData[:11] == "apiVersion:" || req.CAData[:5] == "kind:"))
hasCertAuth := req.CertData != "" && req.KeyData != ""
if hasKubeconfig {
// 使用完整的 kubeconfig 格式
cluster.CAData = req.CAData
} else if hasCertAuth {
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
} else if req.Token != "" {
cluster.SetTokenAuth(req.Token)
@ -57,6 +64,18 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=",
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t",
)
} else {
// 生产模式:没有提供凭证,尝试使用本地 kubeconfig
// 不再返回错误,让 TestConnection 尝试使用本地 kubeconfig
// cluster 保持空的认证信息TestConnection 会使用 KUBECONFIG 环境变量
}
// 测试集群连接(非 mock 模式下)
if os.Getenv("ADAPTER_MODE") != "mock" {
if err := h.clusterService.TestConnection(r.Context(), cluster); err != nil {
respondError(w, http.StatusBadRequest, "Failed to connect to cluster", err.Error())
return
}
}
// 调用领域服务
@ -198,18 +217,24 @@ func (h *ClusterHandler) GetClusterHealth(w http.ResponseWriter, r *http.Request
vars := mux.Vars(r)
clusterID := vars["cluster_id"]
// 检查集群是否存在
_, err := h.clusterService.GetCluster(r.Context(), clusterID)
// 获取集群
cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
if err != nil {
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
return
}
// TODO: 实现真实的健康检查
// 测试连接
err = h.clusterService.TestConnection(r.Context(), cluster)
response := &dto.ClusterHealthResponse{
Healthy: true,
Message: "Cluster is healthy",
Version: "v1.28.0",
Healthy: err == nil,
}
if err != nil {
response.Message = err.Error()
} else {
response.Message = "Cluster is healthy"
}
respondJSON(w, http.StatusOK, response)

View File

@ -54,10 +54,14 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
// 创建实体
instance := entity.NewInstance(
"", // workspaceID - will be set based on user
"", // ownerID - will be set based on user
clusterID,
req.RegistryID,
"", // chartReferenceID - not used in legacy API
"", // valuesTemplateID - not used in legacy API
req.Name,
req.Namespace,
req.RegistryID,
req.Repository,
chart, // Extracted chart name
req.Tag, // Tag mapped to version

View File

@ -41,7 +41,7 @@ func (h *RegistryHandler) CreateRegistry(w http.ResponseWriter, r *http.Request)
}
// 创建实体
registry := entity.NewRegistry(req.Name, req.URL)
registry := entity.NewRegistry("", "", req.Name, req.URL)
registry.Description = req.Description
registry.Insecure = req.Insecure
registry.SetCredentials(req.Username, req.Password)

View File

@ -0,0 +1,291 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// StorageHandler Storage Backend Handler
type StorageHandler struct {
storageService *service.StorageService
}
// NewStorageHandler 创建 Storage Handler
func NewStorageHandler(storageService *service.StorageService) *StorageHandler {
return &StorageHandler{
storageService: storageService,
}
}
// CreateStorage 创建存储后端
// @Summary 创建存储后端
// @Description 新增存储后端配置NFS/PV/hostPath
// @Tags Storage
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateStorageRequest true "存储后端信息"
// @Success 201 {object} dto.StorageResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /storage-backends [post]
func (h *StorageHandler) CreateStorage(w http.ResponseWriter, r *http.Request) {
var req dto.CreateStorageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// 获取用户信息
workspaceID := r.Header.Get("X-Workspace-ID")
ownerID := r.Header.Get("X-User-ID")
// 构建配置
storageType := entity.StorageType(req.Type)
config := entity.StorageConfig{}
switch storageType {
case entity.StorageTypeNFS:
config.NFS = &entity.NFSConfig{
Server: req.NFS.Server,
Path: req.NFS.Path,
}
case entity.StorageTypePV:
config.PV = &entity.PVConfig{
StorageClassName: req.PV.StorageClassName,
Capacity: req.PV.Capacity,
AccessModes: req.PV.AccessModes,
}
case entity.StorageTypeHostPath:
config.HostPath = &entity.HostPathConfig{
Path: req.HostPath.Path,
}
}
// 调用领域服务
storage, err := h.storageService.Create(
r.Context(),
workspaceID,
ownerID,
req.Name,
storageType,
config,
req.Description,
req.IsDefault,
req.IsShared,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to create storage backend", err.Error())
return
}
// 返回响应
response := toStorageResponse(storage)
respondJSON(w, http.StatusCreated, response)
}
// GetStorage 获取存储后端详情
// @Summary 获取存储后端
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Param storage_id path string true "Storage ID"
// @Success 200 {object} dto.StorageResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /storage-backends/{storage_id} [get]
func (h *StorageHandler) GetStorage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
storageID := vars["storage_id"]
storage, err := h.storageService.GetByID(r.Context(), storageID)
if err != nil {
respondError(w, http.StatusNotFound, "Storage backend not found", err.Error())
return
}
response := toStorageResponse(storage)
respondJSON(w, http.StatusOK, response)
}
// GetAllStorage 获取所有存储后端
// @Summary 列出所有存储后端
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Success 200 {array} dto.StorageResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /storage-backends [get]
func (h *StorageHandler) GetAllStorage(w http.ResponseWriter, r *http.Request) {
// 获取 workspace_id从 JWT
workspaceID := r.Header.Get("X-Workspace-ID")
role := r.Header.Get("X-User-Role")
var storages []*entity.StorageBackend
var err error
// Admin 可以看到所有,其他用户只看自己 workspace + 共享的
if role == "admin" {
storages, err = h.storageService.List(r.Context())
} else if workspaceID != "" {
// 获取 workspace 的存储 + 共享存储
workspaceStorages, _ := h.storageService.GetByWorkspace(r.Context(), workspaceID)
sharedStorages, _ := h.storageService.GetShared(r.Context())
// 合并去重
seen := make(map[string]bool)
for _, s := range workspaceStorages {
if !seen[s.ID] {
storages = append(storages, s)
seen[s.ID] = true
}
}
for _, s := range sharedStorages {
if !seen[s.ID] {
storages = append(storages, s)
seen[s.ID] = true
}
}
} else {
// 没有 workspace 的用户只能看到共享存储
storages, err = h.storageService.GetShared(r.Context())
}
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list storage backends", err.Error())
return
}
responses := make([]*dto.StorageResponse, 0, len(storages))
for _, storage := range storages {
responses = append(responses, toStorageResponse(storage))
}
respondJSON(w, http.StatusOK, responses)
}
// UpdateStorage 更新存储后端
// @Summary 更新存储后端
// @Tags Storage
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param storage_id path string true "Storage ID"
// @Param request body dto.UpdateStorageRequest true "更新内容"
// @Success 200 {object} dto.StorageResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /storage-backends/{storage_id} [put]
func (h *StorageHandler) UpdateStorage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
storageID := vars["storage_id"]
var req dto.UpdateStorageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// 构建配置
var storageType entity.StorageType
if req.Type != "" {
storageType = entity.StorageType(req.Type)
}
config := entity.StorageConfig{}
if storageType == entity.StorageTypeNFS && (req.NFS.Server != "" || req.NFS.Path != "") {
config.NFS = &entity.NFSConfig{
Server: req.NFS.Server,
Path: req.NFS.Path,
}
} else if storageType == entity.StorageTypePV && req.PV.StorageClassName != "" {
config.PV = &entity.PVConfig{
StorageClassName: req.PV.StorageClassName,
Capacity: req.PV.Capacity,
AccessModes: req.PV.AccessModes,
}
} else if storageType == entity.StorageTypeHostPath && req.HostPath.Path != "" {
config.HostPath = &entity.HostPathConfig{
Path: req.HostPath.Path,
}
}
storage, err := h.storageService.Update(
r.Context(),
storageID,
req.Name,
req.Description,
storageType,
config,
req.IsDefault,
req.IsShared,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to update storage backend", err.Error())
return
}
response := toStorageResponse(storage)
respondJSON(w, http.StatusOK, response)
}
// DeleteStorage 删除存储后端
// @Summary 删除存储后端
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Param storage_id path string true "Storage ID"
// @Success 204 {string} string "No Content"
// @Failure 404 {object} dto.ErrorResponse
// @Router /storage-backends/{storage_id} [delete]
func (h *StorageHandler) DeleteStorage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
storageID := vars["storage_id"]
if err := h.storageService.Delete(r.Context(), storageID); err != nil {
respondError(w, http.StatusNotFound, "Failed to delete storage backend", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// toStorageResponse 转换为响应 DTO
func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
config := dto.StorageConfigDTO{}
if storage.Config.NFS != nil {
config.NFS = &dto.NFSConfigDTO{
Server: storage.Config.NFS.Server,
Path: storage.Config.NFS.Path,
}
}
if storage.Config.PV != nil {
config.PV = &dto.PVConfigDTO{
StorageClassName: storage.Config.PV.StorageClassName,
Capacity: storage.Config.PV.Capacity,
AccessModes: storage.Config.PV.AccessModes,
}
}
if storage.Config.HostPath != nil {
config.HostPath = &dto.HostPathConfigDTO{
Path: storage.Config.HostPath.Path,
}
}
return &dto.StorageResponse{
ID: storage.ID,
WorkspaceID: storage.WorkspaceID,
OwnerID: storage.OwnerID,
Name: storage.Name,
Type: string(storage.Type),
Config: config,
Description: storage.Description,
IsDefault: storage.IsDefault,
IsShared: storage.IsShared,
CreatedAt: storage.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: storage.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

View File

@ -0,0 +1,152 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// UserHandler 用户 HTTP 处理程序
type UserHandler struct {
authService *service.AuthService
workspaceService *service.WorkspaceService
}
// NewUserHandler 创建用户处理程序
func NewUserHandler(authService *service.AuthService, workspaceService *service.WorkspaceService) *UserHandler {
return &UserHandler{
authService: authService,
workspaceService: workspaceService,
}
}
// GetCurrentUser 获取当前用户信息
// @Summary 获取当前用户信息
// @Description 获取当前登录用户的基本信息
// @Tags user
// @Accept json
// @Produce json
// @Success 200 {object} dto.UserResponseWithDTO
// @Router /users/me [get]
func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
userID := GetUserIDFromRequest(r)
if userID == "" {
respondError(w, http.StatusUnauthorized, "Not authenticated", "")
return
}
user, err := h.authService.GetUserByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found", "")
return
}
// 获取 workspace 名称
workspaceName := ""
if user.WorkspaceID != "" {
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
if ws != nil {
workspaceName = ws.Name
}
}
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
}
// ChangePassword 修改当前用户密码
// @Summary 修改当前用户密码
// @Description 修改当前登录用户的密码
// @Tags user
// @Accept json
// @Produce json
// @Param request body dto.ChangePasswordRequest true "修改密码请求"
// @Success 200
// @Router /users/me/password [put]
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
userID := GetUserIDFromRequest(r)
if userID == "" {
respondError(w, http.StatusUnauthorized, "Not authenticated", "")
return
}
var req dto.ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
err := h.authService.ChangePassword(r.Context(), userID, req.OldPassword, req.NewPassword)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "Password changed successfully", map[string]string{"message": "Password changed successfully"})
}
// GetCurrentUserWorkspace 获取当前用户所属的 Workspace
// @Summary 获取当前用户所属工作空间
// @Description 获取当前用户所属工作空间的详细信息和配额
// @Tags user
// @Accept json
// @Produce json
// @Success 200 {object} dto.WorkspaceResponse
// @Router /users/me/workspace [get]
func (h *UserHandler) GetCurrentUserWorkspace(w http.ResponseWriter, r *http.Request) {
userID := GetUserIDFromRequest(r)
if userID == "" {
respondError(w, http.StatusUnauthorized, "Not authenticated", "")
return
}
user, err := h.authService.GetUserByID(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found", "")
return
}
// Admin 没有 workspace
if user.Role == entity.RoleAdmin {
respondSuccess(w, "", nil)
return
}
if user.WorkspaceID == "" {
respondSuccess(w, "", nil)
return
}
workspace, err := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
if err != nil {
respondError(w, http.StatusNotFound, "Workspace not found", "")
return
}
// 获取配额
quotas, _ := h.workspaceService.GetQuotas(r.Context(), workspace.ID)
response := dto.WorkspaceResponse{
Workspace: dto.WorkspaceDTOFromEntity(workspace),
Quotas: dto.QuotaDTOsFromEntities(quotas),
}
respondSuccess(w, "", response)
}
// GetUserIDFromRequest 从请求中获取用户 ID
func GetUserIDFromRequest(r *http.Request) string {
// 尝试从 Header 获取(由中间件设置)
userID := r.Header.Get("X-User-ID")
if userID != "" {
return userID
}
// 尝试从 Context 获取(安全类型断言)
if uid, ok := r.Context().Value("user_id").(string); ok {
return uid
}
return ""
}

View File

@ -0,0 +1,332 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// UserManagementHandler 用户管理 HTTP 处理程序
type UserManagementHandler struct {
userManagementService *service.UserManagementService
authService *service.AuthService
workspaceService *service.WorkspaceService
}
// NewUserManagementHandler 创建用户管理处理程序
func NewUserManagementHandler(
userManagementService *service.UserManagementService,
authService *service.AuthService,
workspaceService *service.WorkspaceService,
) *UserManagementHandler {
return &UserManagementHandler{
userManagementService: userManagementService,
authService: authService,
workspaceService: workspaceService,
}
}
// CreateUser 创建用户Admin 操作)
// @Summary 创建用户
// @Description 创建新用户Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param request body dto.CreateUserRequest true "创建用户请求"
// @Success 200 {object} dto.UserResponseWithDTO
// @Router /admin/users [post]
func (h *UserManagementHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
var req dto.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
user, err := h.userManagementService.CreateUser(r.Context(), req.Username, req.Password, req.Email, req.Role, req.WorkspaceID)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
// 获取 workspace 名称
workspaceName := ""
if user.WorkspaceID != "" {
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
if ws != nil {
workspaceName = ws.Name
}
}
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
}
// GetUser 获取用户
// @Summary 获取用户
// @Description 获取指定用户信息Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param user_id path string true "用户 ID"
// @Success 200 {object} dto.UserResponseWithDTO
// @Router /admin/users/{user_id} [get]
func (h *UserManagementHandler) GetUser(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
userID := vars["user_id"]
user, err := h.userManagementService.GetUser(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found", "")
return
}
// 获取 workspace 名称
workspaceName := ""
if user.WorkspaceID != "" {
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
if ws != nil {
workspaceName = ws.Name
}
}
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
}
// ListUsers 列出用户
// @Summary 列出用户
// @Description 获取所有用户列表Admin 专用),可按 workspace_id 筛选
// @Tags admin
// @Accept json
// @Produce json
// @Param workspace_id query string false "工作空间 ID"
// @Success 200 {object} dto.UserListResponse
// @Router /admin/users [get]
func (h *UserManagementHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
workspaceID := r.URL.Query().Get("workspace_id")
users, err := h.userManagementService.ListUsers(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error(), "")
return
}
// 获取所有 workspace 名称
workspaceNames := make(map[string]string)
workspaces, _ := h.workspaceService.List(r.Context())
for _, ws := range workspaces {
workspaceNames[ws.ID] = ws.Name
}
respondSuccess(w, "", dto.UserListResponse{
Users: dto.UserDTOsFromEntities(users, workspaceNames),
Total: len(users),
})
}
// UpdateUser 更新用户
// @Summary 更新用户
// @Description 更新用户信息Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param user_id path string true "用户 ID"
// @Param request body dto.UpdateUserRequest true "更新用户请求"
// @Success 200 {object} dto.UserResponseWithDTO
// @Router /admin/users/{user_id} [put]
func (h *UserManagementHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
userID := vars["user_id"]
user, err := h.userManagementService.GetUser(r.Context(), userID)
if err != nil {
respondError(w, http.StatusNotFound, "User not found", "")
return
}
var req dto.UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
if req.Email != "" {
user.Email = req.Email
}
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
if err := h.userManagementService.UpdateUser(r.Context(), user); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
// 获取 workspace 名称
workspaceName := ""
if user.WorkspaceID != "" {
ws, _ := h.workspaceService.GetByID(r.Context(), user.WorkspaceID)
if ws != nil {
workspaceName = ws.Name
}
}
respondSuccess(w, "", dto.UserResponseWithDTO{User: dto.UserDTOFromEntity(user, workspaceName)})
}
// SetUserActive 启用/禁用用户
// @Summary 启用/禁用用户
// @Description 设置用户是否启用Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param user_id path string true "用户 ID"
// @Param request body dto.SetUserActiveRequest true "启用状态"
// @Success 200
// @Router /admin/users/{user_id}/active [put]
func (h *UserManagementHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
userID := vars["user_id"]
var req dto.SetUserActiveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
if err := h.userManagementService.SetUserActive(r.Context(), userID, req.IsActive); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", nil)
}
// ChangeUserWorkspace 分配用户到 Workspace
// @Summary 分配用户到工作空间
// @Description 将用户分配到指定工作空间Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param user_id path string true "用户 ID"
// @Param request body dto.ChangeUserWorkspaceRequest true "工作空间分配请求"
// @Success 200
// @Router /admin/users/{user_id}/workspace [put]
func (h *UserManagementHandler) ChangeUserWorkspace(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
userID := vars["user_id"]
var req dto.ChangeUserWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
if err := h.userManagementService.ChangeUserWorkspace(r.Context(), userID, req.WorkspaceID); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", nil)
}
// ResetPassword 重置用户密码Admin 操作)
// @Summary 重置用户密码
// @Description 重置指定用户的密码Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param user_id path string true "用户 ID"
// @Param request body dto.ResetPasswordRequest true "重置密码请求"
// @Success 200
// @Router /admin/users/{user_id}/password [put]
func (h *UserManagementHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
userID := vars["user_id"]
var req dto.ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
if err := h.userManagementService.ResetPassword(r.Context(), userID, req.NewPassword); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", nil)
}
// DeleteUser 删除用户
// @Summary 删除用户
// @Description 删除指定用户Admin 专用)
// @Tags admin
// @Accept json
// @Produce json
// @Param user_id path string true "用户 ID"
// @Success 200
// @Router /admin/users/{user_id} [delete]
func (h *UserManagementHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
userID := vars["user_id"]
if err := h.userManagementService.DeleteUser(r.Context(), userID); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", nil)
}
// requireAdmin 检查是否为 Admin
func (h *UserManagementHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
userRole := r.Header.Get("X-User-Role")
if userRole != string(entity.RoleAdmin) {
respondError(w, http.StatusForbidden, "Admin access required", "")
return false
}
return true
}

View File

@ -0,0 +1,294 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// ValuesTemplateHandler Values Template Handler
type ValuesTemplateHandler struct {
valuesTemplateService *service.ValuesTemplateService
}
// NewValuesTemplateHandler 创建 Values Template Handler
func NewValuesTemplateHandler(valuesTemplateService *service.ValuesTemplateService) *ValuesTemplateHandler {
return &ValuesTemplateHandler{
valuesTemplateService: valuesTemplateService,
}
}
// CreateValuesTemplate 创建 Values 模板
// @Summary 创建 Values 模板
// @Description 新增 Values 模板配置(带版本管理)
// @Tags Values Templates
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body dto.CreateValuesTemplateRequest true "Values 模板信息"
// @Success 201 {object} dto.ValuesTemplateResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /values-templates [post]
func (h *ValuesTemplateHandler) CreateValuesTemplate(w http.ResponseWriter, r *http.Request) {
var req dto.CreateValuesTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
// 获取用户信息
workspaceID := r.Header.Get("X-Workspace-ID")
ownerID := r.Header.Get("X-User-ID")
template, err := h.valuesTemplateService.Create(
r.Context(),
workspaceID,
ownerID,
req.ChartReferenceID,
req.Name,
req.Description,
req.ValuesYAML,
req.IsDefault,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to create values template", err.Error())
return
}
response := toValuesTemplateResponse(template)
respondJSON(w, http.StatusCreated, response)
}
// GetValuesTemplate 获取 Values 模板详情
// @Summary 获取 Values 模板
// @Tags Values Templates
// @Produce json
// @Security BearerAuth
// @Param template_id path string true "Template ID"
// @Success 200 {object} dto.ValuesTemplateResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /values-templates/{template_id} [get]
func (h *ValuesTemplateHandler) GetValuesTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
templateID := vars["template_id"]
template, err := h.valuesTemplateService.GetByID(r.Context(), templateID)
if err != nil {
respondError(w, http.StatusNotFound, "Values template not found", err.Error())
return
}
response := toValuesTemplateResponse(template)
respondJSON(w, http.StatusOK, response)
}
// GetAllValuesTemplates 获取所有 Values 模板
// @Summary 列出所有 Values 模板
// @Tags Values Templates
// @Produce json
// @Security BearerAuth
// @Success 200 {array} dto.ValuesTemplateResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /values-templates [get]
func (h *ValuesTemplateHandler) GetAllValuesTemplates(w http.ResponseWriter, r *http.Request) {
workspaceID := r.Header.Get("X-Workspace-ID")
role := r.Header.Get("X-User-Role")
var templates []*dto.ValuesTemplateResponse
// Admin 可以看到所有,其他用户只看自己 workspace
if role == "admin" {
allTemplates, err := h.valuesTemplateService.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list values templates", err.Error())
return
}
for _, t := range allTemplates {
templates = append(templates, toValuesTemplateResponse(t))
}
} else if workspaceID != "" {
workspaceTemplates, err := h.valuesTemplateService.GetByWorkspace(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list values templates", err.Error())
return
}
for _, t := range workspaceTemplates {
templates = append(templates, toValuesTemplateResponse(t))
}
}
respondJSON(w, http.StatusOK, templates)
}
// GetValuesTemplatesByChartReference 获取 Chart Reference 的所有 Values 模板
// @Summary 获取 Chart Reference 的 Values 模板
// @Tags Values Templates
// @Produce json
// @Security BearerAuth
// @Param chart_reference_id path string true "Chart Reference ID"
// @Success 200 {array} dto.ValuesTemplateResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /chart-references/{chart_reference_id}/values-templates [get]
func (h *ValuesTemplateHandler) GetValuesTemplatesByChartReference(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
chartRefID := vars["chart_reference_id"]
templates, err := h.valuesTemplateService.GetByChartReference(r.Context(), chartRefID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list values templates", err.Error())
return
}
responses := make([]*dto.ValuesTemplateResponse, 0, len(templates))
for _, t := range templates {
responses = append(responses, toValuesTemplateResponse(t))
}
respondJSON(w, http.StatusOK, responses)
}
// GetValuesTemplateHistory 获取模板的版本历史
// @Summary 获取 Values 模板版本历史
// @Tags Values Templates
// @Produce json
// @Security BearerAuth
// @Param chart_reference_id path string true "Chart Reference ID"
// @Param name query string true "Template Name"
// @Success 200 {array} dto.ValuesTemplateResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /chart-references/{chart_reference_id}/values-templates/history [get]
func (h *ValuesTemplateHandler) GetValuesTemplateHistory(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
chartRefID := vars["chart_reference_id"]
name := r.URL.Query().Get("name")
if name == "" {
respondError(w, http.StatusBadRequest, "Template name is required", "")
return
}
templates, err := h.valuesTemplateService.GetHistory(r.Context(), chartRefID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get values template history", err.Error())
return
}
responses := make([]*dto.ValuesTemplateResponse, 0, len(templates))
for _, t := range templates {
responses = append(responses, toValuesTemplateResponse(t))
}
respondJSON(w, http.StatusOK, responses)
}
// UpdateValuesTemplate 更新 Values 模板
// @Summary 更新 Values 模板
// @Tags Values Templates
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param template_id path string true "Template ID"
// @Param request body dto.UpdateValuesTemplateRequest true "更新内容"
// @Success 200 {object} dto.ValuesTemplateResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /values-templates/{template_id} [put]
func (h *ValuesTemplateHandler) UpdateValuesTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
templateID := vars["template_id"]
var req dto.UpdateValuesTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
isDefault := false
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
template, err := h.valuesTemplateService.Update(
r.Context(),
templateID,
req.Description,
req.ValuesYAML,
isDefault,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to update values template", err.Error())
return
}
response := toValuesTemplateResponse(template)
respondJSON(w, http.StatusOK, response)
}
// DeleteValuesTemplate 删除 Values 模板
// @Summary 删除 Values 模板
// @Tags Values Templates
// @Produce json
// @Security BearerAuth
// @Param template_id path string true "Template ID"
// @Success 204 {string} string "No Content"
// @Failure 404 {object} dto.ErrorResponse
// @Router /values-templates/{template_id} [delete]
func (h *ValuesTemplateHandler) DeleteValuesTemplate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
templateID := vars["template_id"]
if err := h.valuesTemplateService.Delete(r.Context(), templateID); err != nil {
respondError(w, http.StatusNotFound, "Failed to delete values template", err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// RollbackValuesTemplate 回滚到指定版本
// @Summary 回滚 Values 模板
// @Tags Values Templates
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param chart_reference_id path string true "Chart Reference ID"
// @Param request body dto.RollbackValuesTemplateRequest true "回滚信息"
// @Success 200 {object} dto.ValuesTemplateResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /chart-references/{chart_reference_id}/values-templates/rollback [post]
func (h *ValuesTemplateHandler) RollbackValuesTemplate(w http.ResponseWriter, r *http.Request) {
var req dto.RollbackValuesTemplateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
template, err := h.valuesTemplateService.Rollback(r.Context(), req.TemplateID)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to rollback values template", err.Error())
return
}
response := toValuesTemplateResponse(template)
respondJSON(w, http.StatusOK, response)
}
// toValuesTemplateResponse 转换为响应 DTO
func toValuesTemplateResponse(template *entity.ValuesTemplate) *dto.ValuesTemplateResponse {
return &dto.ValuesTemplateResponse{
ID: template.ID,
WorkspaceID: template.WorkspaceID,
OwnerID: template.OwnerID,
ChartReferenceID: template.ChartReferenceID,
Name: template.Name,
Description: template.Description,
ValuesYAML: template.ValuesYAML,
Version: template.Version,
IsDefault: template.IsDefault,
CreatedAt: template.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: template.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}

View File

@ -0,0 +1,306 @@
package rest
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
)
// WorkspaceHandler 工作空间 HTTP 处理程序
type WorkspaceHandler struct {
workspaceService *service.WorkspaceService
authService *service.AuthService
}
// NewWorkspaceHandler 创建工作空间处理程序
func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService *service.AuthService) *WorkspaceHandler {
return &WorkspaceHandler{
workspaceService: workspaceService,
authService: authService,
}
}
// CreateWorkspace 创建工作空间
// @Summary 创建工作空间
// @Description 创建新的工作空间Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Param request body dto.CreateWorkspaceRequest true "创建工作空间请求"
// @Success 200 {object} dto.WorkspaceDTO
// @Router /workspaces [post]
func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
var req dto.CreateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
// 获取创建者 ID
userID := GetUserIDFromRequest(r)
workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
}
// GetWorkspace 获取工作空间
// @Summary 获取工作空间
// @Description 获取指定工作空间的详细信息和配额
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Success 200 {object} dto.WorkspaceResponse
// @Router /workspaces/{workspace_id} [get]
func (h *WorkspaceHandler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusNotFound, "Workspace not found", "")
return
}
// 检查访问权限
if !h.canAccessWorkspace(w, r, workspace.ID) {
return
}
// 获取配额
quotas, _ := h.workspaceService.GetQuotas(r.Context(), workspace.ID)
response := dto.WorkspaceResponse{
Workspace: dto.WorkspaceDTOFromEntity(workspace),
Quotas: dto.QuotaDTOsFromEntities(quotas),
}
respondSuccess(w, "", response)
}
// UpdateWorkspace 更新工作空间
// @Summary 更新工作空间
// @Description 更新工作空间信息Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Param request body dto.UpdateWorkspaceRequest true "更新工作空间请求"
// @Success 200 {object} dto.WorkspaceDTO
// @Router /workspaces/{workspace_id} [put]
func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
workspace, err := h.workspaceService.GetByID(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusNotFound, "Workspace not found", "")
return
}
var req dto.UpdateWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
if req.Name != "" {
workspace.Name = req.Name
}
if req.Description != "" {
workspace.Description = req.Description
}
if err := h.workspaceService.Update(r.Context(), workspace); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", dto.WorkspaceDTOFromEntity(workspace))
}
// DeleteWorkspace 删除工作空间
// @Summary 删除工作空间
// @Description 删除指定工作空间Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Success 200
// @Router /workspaces/{workspace_id} [delete]
func (h *WorkspaceHandler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
if err := h.workspaceService.Delete(r.Context(), workspaceID); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
respondSuccess(w, "", nil)
}
// ListWorkspaces 列出所有工作空间
// @Summary 列出所有工作空间
// @Description 获取所有工作空间列表Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Success 200 {object} dto.WorkspaceListResponse
// @Router /workspaces [get]
func (h *WorkspaceHandler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
workspaces, err := h.workspaceService.List(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error(), "")
return
}
respondSuccess(w, "", dto.WorkspaceListResponse{
Workspaces: dto.WorkspaceDTOsFromEntities(workspaces),
Total: len(workspaces),
})
}
// GetWorkspaceQuotas 获取工作空间配额
// @Summary 获取工作空间配额
// @Description 获取指定工作空间的资源配额
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Success 200 {array} dto.QuotaDTO
// @Router /workspaces/{workspace_id}/quotas [get]
func (h *WorkspaceHandler) GetWorkspaceQuotas(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
// 检查访问权限
if !h.canAccessWorkspace(w, r, workspaceID) {
return
}
quotas, err := h.workspaceService.GetQuotas(r.Context(), workspaceID)
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error(), "")
return
}
respondSuccess(w, "", dto.QuotaDTOsFromEntities(quotas))
}
// SetWorkspaceQuotas 设置工作空间配额
// @Summary 设置工作空间配额
// @Description 设置指定工作空间的 CPU/GPU/GPU Memory 配额Admin 专用)
// @Tags workspace
// @Accept json
// @Produce json
// @Param workspace_id path string true "工作空间 ID"
// @Param request body dto.SetQuotasRequest true "配额设置请求"
// @Success 200 {array} dto.QuotaDTO
// @Router /workspaces/{workspace_id}/quotas [put]
func (h *WorkspaceHandler) SetWorkspaceQuotas(w http.ResponseWriter, r *http.Request) {
// 检查权限Admin
if !h.requireAdmin(w, r) {
return
}
vars := mux.Vars(r)
workspaceID := vars["workspace_id"]
var req dto.SetQuotasRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", "")
return
}
quotas := make(map[entity.ResourceType]struct {
HardLimit float64
SoftLimit float64
})
if req.CPU != nil {
quotas[entity.ResourceCPU] = struct {
HardLimit float64
SoftLimit float64
}{req.CPU.HardLimit, req.CPU.SoftLimit}
}
if req.GPU != nil {
quotas[entity.ResourceGPU] = struct {
HardLimit float64
SoftLimit float64
}{req.GPU.HardLimit, req.GPU.SoftLimit}
}
if req.GPUMemory != nil {
quotas[entity.ResourceGPUMemory] = struct {
HardLimit float64
SoftLimit float64
}{req.GPUMemory.HardLimit, req.GPUMemory.SoftLimit}
}
if err := h.workspaceService.SetQuotas(r.Context(), workspaceID, quotas); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
}
// 返回更新后的配额
updatedQuotas, _ := h.workspaceService.GetQuotas(r.Context(), workspaceID)
respondSuccess(w, "", dto.QuotaDTOsFromEntities(updatedQuotas))
}
// requireAdmin 检查是否为 Admin
func (h *WorkspaceHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
userRole := r.Header.Get("X-User-Role")
if userRole != string(entity.RoleAdmin) {
respondError(w, http.StatusForbidden, "Admin access required", "")
return false
}
return true
}
// canAccessWorkspace 检查是否可以访问工作空间
func (h *WorkspaceHandler) canAccessWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool {
userRole := r.Header.Get("X-User-Role")
userWorkspaceID := r.Header.Get("X-Workspace-ID")
// Admin 可以访问所有
if userRole == string(entity.RoleAdmin) {
return true
}
// 普通用户只能访问自己的 workspace
if userWorkspaceID != workspaceID {
respondError(w, http.StatusForbidden, "Access denied", "")
return false
}
return true
}

View File

@ -127,6 +127,69 @@ func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
return k8s.NewEntryClient()
}
// CreateWorkspaceRepository 创建 Workspace 仓储
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("workspace repository mock not implemented")
}
// 默认真实实现PostgreSQL
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewWorkspaceRepository(f.db), nil
}
// CreateQuotaRepository 创建 Quota 仓储
// CreateStorageRepository 创建存储后端仓储
func (f *AdapterFactory) CreateStorageRepository() (repository.StorageRepository, error) {
if f.mode == ModeMock {
return mock.NewStorageRepositoryMock(), nil
}
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewStorageRepository(f.db), nil
}
// CreateQuotaRepository 创建配额仓储
func (f *AdapterFactory) CreateQuotaRepository() (repository.QuotaRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("quota repository mock not implemented")
}
// 默认真实实现PostgreSQL
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewQuotaRepository(f.db), nil
}
// CreateChartReferenceRepository 创建 Chart 引用仓储
func (f *AdapterFactory) CreateChartReferenceRepository() (repository.ChartReferenceRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("chart reference repository mock not implemented")
}
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewChartReferenceRepository(f.db), nil
}
// CreateValuesTemplateRepository 创建 Values 模板仓储
func (f *AdapterFactory) CreateValuesTemplateRepository() (repository.ValuesTemplateRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("values template repository mock not implemented")
}
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewValuesTemplateRepository(f.db), nil
}
// CreateAllRepositories 一次性创建所有 Repositories
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
userRepo, err := f.CreateUserRepository()
@ -149,6 +212,21 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
return nil, fmt.Errorf("failed to create instance repository: %w", err)
}
workspaceRepo, err := f.CreateWorkspaceRepository()
if err != nil {
return nil, fmt.Errorf("failed to create workspace repository: %w", err)
}
storageRepo, err := f.CreateStorageRepository()
if err != nil {
return nil, fmt.Errorf("failed to create storage repository: %w", err)
}
quotaRepo, err := f.CreateQuotaRepository()
if err != nil {
return nil, fmt.Errorf("failed to create quota repository: %w", err)
}
ociClient, err := f.CreateOCIClient()
if err != nil {
return nil, fmt.Errorf("failed to create OCI client: %w", err)
@ -163,28 +241,48 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
metricsClient := f.CreateMetricsClient(clusterRepo)
entryClient := f.CreateEntryClient()
chartRefRepo, err := f.CreateChartReferenceRepository()
if err != nil {
return nil, fmt.Errorf("failed to create chart reference repository: %w", err)
}
valuesTemplateRepo, err := f.CreateValuesTemplateRepository()
if err != nil {
return nil, fmt.Errorf("failed to create values template repository: %w", err)
}
return &Repositories{
UserRepo: userRepo,
ClusterRepo: clusterRepo,
RegistryRepo: registryRepo,
InstanceRepo: instanceRepo,
OCIClient: ociClient,
HelmClient: helmClient,
MetricsClient: metricsClient,
EntryClient: entryClient,
UserRepo: userRepo,
ClusterRepo: clusterRepo,
RegistryRepo: registryRepo,
InstanceRepo: instanceRepo,
WorkspaceRepo: workspaceRepo,
StorageRepo: storageRepo,
ChartRefRepo: chartRefRepo,
ValuesTemplateRepo: valuesTemplateRepo,
QuotaRepo: quotaRepo,
OCIClient: ociClient,
HelmClient: helmClient,
MetricsClient: metricsClient,
EntryClient: entryClient,
}, nil
}
// Repositories 所有仓储的集合
type Repositories struct {
UserRepo repository.UserRepository
ClusterRepo repository.ClusterRepository
RegistryRepo repository.RegistryRepository
InstanceRepo repository.InstanceRepository
OCIClient repository.OCIClient
HelmClient repository.HelmClient
MetricsClient repository.MetricsClient
EntryClient repository.InstanceEntryClient
UserRepo repository.UserRepository
ClusterRepo repository.ClusterRepository
RegistryRepo repository.RegistryRepository
InstanceRepo repository.InstanceRepository
WorkspaceRepo repository.WorkspaceRepository
StorageRepo repository.StorageRepository
ChartRefRepo repository.ChartReferenceRepository
ValuesTemplateRepo repository.ValuesTemplateRepository
QuotaRepo repository.QuotaRepository
OCIClient repository.OCIClient
HelmClient repository.HelmClient
MetricsClient repository.MetricsClient
EntryClient repository.InstanceEntryClient
}
// ensureDBConnection 确保数据库连接已建立

View File

@ -262,12 +262,40 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
return mockSchema, nil
}
func (c *OCIClientMock) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
artifact, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil {
return "", err
}
if !artifact.IsChart() {
return "", fmt.Errorf("not a helm chart")
}
// 返回 Mock values.yaml
mockValues := `# Default values for the chart
replicaCount: 1
image:
repository: nginx
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources: {}
`
return mockValues, nil
}
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
_, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil {
return err
}
// Mock 实现,不实际下载
return nil
}

View File

@ -43,13 +43,26 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
return nil, fmt.Errorf("failed to create registry client: %w", err)
}
// 设置认证
if reg.Username != "" && reg.Password != "" {
// 设置认证 - 优先使用 registry 自己的凭证,否则使用 .env 中的默认凭证
username := reg.Username
password := reg.Password
// 如果没有提供凭证,尝试从环境变量加载
if (username == "" || password == "") && strings.Contains(reg.URL, "harbor") {
if envUser := os.Getenv("HARBOR_USERNAME"); envUser != "" {
username = envUser
}
if envPass := os.Getenv("HARBOR_PASSWORD"); envPass != "" {
password = envPass
}
}
if username != "" && password != "" {
registry.Client = &auth.Client{
Client: c.httpClient,
Credential: auth.StaticCredential(registryURL, auth.Credential{
Username: reg.Username,
Password: reg.Password,
Username: username,
Password: password,
}),
}
}
@ -370,6 +383,105 @@ func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Regist
return "", entity.ErrValuesSchemaNotFound
}
// GetValues 获取 Helm Chart 的 values.yaml
func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
reg, err := c.getRegistry(registry)
if err != nil {
return "", err
}
repo, err := reg.Repository(ctx, repository)
if err != nil {
return "", fmt.Errorf("failed to get repository: %w", err)
}
// 解析 reference (tag 或 digest)
desc, err := repo.Resolve(ctx, reference)
if err != nil {
return "", fmt.Errorf("failed to resolve artifact: %w", err)
}
manifestReader, err := repo.Fetch(ctx, desc)
if err != nil {
return "", fmt.Errorf("failed to fetch manifest: %w", err)
}
defer manifestReader.Close()
manifestBytes, err := io.ReadAll(manifestReader)
if err != nil {
return "", fmt.Errorf("failed to read manifest: %w", err)
}
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return "", fmt.Errorf("failed to unmarshal manifest: %w", err)
}
// 查找 Helm Chart layertar+gzip 包含 chart 内容)并从中读取 values.yaml
var chartLayer *ocispec.Descriptor
for i := range manifest.Layers {
layer := manifest.Layers[i]
if strings.Contains(layer.MediaType, "cncf.helm.chart") ||
strings.Contains(layer.MediaType, "helm.chart.content") {
chartLayer = &manifest.Layers[i]
break
}
}
if chartLayer == nil {
return "", entity.ErrValuesNotFound
}
if chartLayer.Digest == "" {
return "", fmt.Errorf("chart layer digest is empty")
}
if _, err := digest.Parse(string(chartLayer.Digest)); err != nil {
return "", fmt.Errorf("invalid chart layer digest: %w", err)
}
layerReader, err := repo.Fetch(ctx, *chartLayer)
if err != nil {
return "", fmt.Errorf("failed to fetch chart layer: %w", err)
}
defer layerReader.Close()
gzipReader, err := gzip.NewReader(layerReader)
if err != nil {
return "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read chart archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
// 查找 values.yaml 文件(可能在 chart 根目录或子目录中)
// 通常路径格式为: {chart-name}/values.yaml
if strings.HasSuffix(header.Name, "values.yaml") {
data, err := io.ReadAll(tarReader)
if err != nil {
return "", fmt.Errorf("failed to read values.yaml: %w", err)
}
if len(data) == 0 {
return "", entity.ErrValuesNotFound
}
return string(data), nil
}
}
return "", entity.ErrValuesNotFound
}
// PullArtifact 下载 artifact 到本地
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
reg, err := c.getRegistry(registry)

View File

@ -104,13 +104,41 @@ func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0, len(r.clusters))
for _, cluster := range r.clusters {
// 解密敏感数据后返回
clusters = append(clusters, r.decryptCluster(cluster))
}
return clusters, nil
}
func (r *ClusterRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0)
for _, cluster := range r.clusters {
if cluster.WorkspaceID == workspaceID {
clusters = append(clusters, r.decryptCluster(cluster))
}
}
return clusters, nil
}
func (r *ClusterRepositoryMock) GetShared(ctx context.Context) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0)
for _, cluster := range r.clusters {
if cluster.IsShared {
clusters = append(clusters, r.decryptCluster(cluster))
}
}
return clusters, nil
}

View File

@ -102,12 +102,26 @@ func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID st
func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) {
r.mu.RLock()
defer r.mu.RUnlock()
instances := make([]*entity.Instance, 0, len(r.instances))
for _, instance := range r.instances {
instances = append(instances, instance)
}
return instances, nil
}
func (r *InstanceRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
r.mu.RLock()
defer r.mu.RUnlock()
instances := make([]*entity.Instance, 0)
for _, instance := range r.instances {
if instance.WorkspaceID == workspaceID {
instances = append(instances, instance)
}
}
return instances, nil
}

View File

@ -0,0 +1,96 @@
package mock
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// StorageRepositoryMock Storage 仓储 Mock
type StorageRepositoryMock struct {
storages map[string]*entity.StorageBackend
}
// NewStorageRepositoryMock 创建 Mock
func NewStorageRepositoryMock() *StorageRepositoryMock {
return &StorageRepositoryMock{
storages: make(map[string]*entity.StorageBackend),
}
}
// Create 创建存储
func (r *StorageRepositoryMock) Create(ctx context.Context, storage *entity.StorageBackend) error {
r.storages[storage.ID] = storage
return nil
}
// GetByID 获取存储
func (r *StorageRepositoryMock) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
if s, ok := r.storages[id]; ok {
return s, nil
}
return nil, entity.ErrStorageNotFound
}
// GetByWorkspace 获取工作空间的存储
func (r *StorageRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
if s.WorkspaceID == workspaceID {
result = append(result, s)
}
}
return result, nil
}
// GetByName 按名称获取
func (r *StorageRepositoryMock) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
for _, s := range r.storages {
if s.WorkspaceID == workspaceID && s.Name == name {
return s, nil
}
}
return nil, entity.ErrStorageNotFound
}
// Update 更新存储
func (r *StorageRepositoryMock) Update(ctx context.Context, storage *entity.StorageBackend) error {
r.storages[storage.ID] = storage
return nil
}
// Delete 删除存储
func (r *StorageRepositoryMock) Delete(ctx context.Context, id string) error {
delete(r.storages, id)
return nil
}
// GetShared 获取所有共享存储后端
func (r *StorageRepositoryMock) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
if s.IsShared {
result = append(result, s)
}
}
return result, nil
}
// GetDefault 获取 workspace 的默认存储后端
func (r *StorageRepositoryMock) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
for _, s := range r.storages {
if s.WorkspaceID == workspaceID && s.IsDefault {
return s, nil
}
}
return nil, entity.ErrStorageNotFound
}
// List 列出所有存储(管理员用)
func (r *StorageRepositoryMock) List(ctx context.Context) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
result = append(result, s)
}
return result, nil
}

View File

@ -88,12 +88,40 @@ func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*entity.User, 0, len(r.users))
for _, user := range r.users {
users = append(users, user)
}
return users, nil
}
func (r *UserRepositoryMock) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*entity.User, 0)
for _, user := range r.users {
if user.WorkspaceID == workspaceID {
users = append(users, user)
}
}
return users, nil
}
func (r *UserRepositoryMock) ListActive(ctx context.Context) ([]*entity.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*entity.User, 0)
for _, user := range r.users {
if user.IsActive {
users = append(users, user)
}
}
return users, nil
}

View File

@ -0,0 +1,200 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// AuditLogRepository PostgreSQL 审计日志仓储实现
type AuditLogRepository struct {
db *DB
}
// NewAuditLogRepository 创建 PostgreSQL 审计日志仓储
func NewAuditLogRepository(db *DB) repository.AuditLogRepository {
return &AuditLogRepository{db: db}
}
// Create 创建审计日志
func (r *AuditLogRepository) Create(ctx context.Context, log *entity.AuditLog) error {
if log.ID == "" {
log.ID = uuid.New().String()
}
detailsJSON, err := json.Marshal(log.Details)
if err != nil {
return fmt.Errorf("failed to marshal details: %w", err)
}
query := `
INSERT INTO audit_logs (id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err = r.db.conn.ExecContext(ctx, query,
log.ID,
log.WorkspaceID,
log.UserID,
log.Action,
log.ResourceType,
log.ResourceID,
log.ResourceName,
detailsJSON,
log.IPAddress,
log.UserAgent,
log.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
}
// GetByWorkspace 获取 workspace 的审计日志
func (r *AuditLogRepository) GetByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
WHERE workspace_id = $1
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// GetByUser 获取用户的审计日志
func (r *AuditLogRepository) GetByUser(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.conn.QueryContext(ctx, query, userID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// GetByResource 获取资源的审计日志
func (r *AuditLogRepository) GetByResource(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT id, workspace_id, user_id, admin action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
WHERE resource_type = $1 AND resource_id = $2
ORDER BY created_at DESC
LIMIT $3
`
rows, err := r.db.conn.QueryContext(ctx, query, resourceType, resourceID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// List 列出审计日志(分页)
func (r *AuditLogRepository) List(ctx context.Context, limit, offset int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
if offset < 0 {
offset = 0
}
query := `
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
rows, err := r.db.conn.QueryContext(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// DeleteByWorkspace 删除 workspace 的审计日志
func (r *AuditLogRepository) DeleteByWorkspace(ctx context.Context, workspaceID string) error {
query := `DELETE FROM audit_logs WHERE workspace_id = $1`
_, err := r.db.conn.ExecContext(ctx, query, workspaceID)
if err != nil {
return fmt.Errorf("failed to delete audit logs: %w", err)
}
return nil
}
// scanAuditLogs 扫描多行结果
func (r *AuditLogRepository) scanAuditLogs(rows *sql.Rows) ([]*entity.AuditLog, error) {
logs := make([]*entity.AuditLog, 0)
for rows.Next() {
log := &entity.AuditLog{}
var detailsJSON []byte
err := rows.Scan(
&log.ID,
&log.WorkspaceID,
&log.UserID,
&log.Action,
&log.ResourceType,
&log.ResourceID,
&log.ResourceName,
&detailsJSON,
&log.IPAddress,
&log.UserAgent,
&log.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan audit log: %w", err)
}
if err := json.Unmarshal(detailsJSON, &log.Details); err != nil {
log.Details = make(map[string]interface{})
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return logs, nil
}

View File

@ -0,0 +1,253 @@
package postgres
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"
)
// ChartReferenceRepository PostgreSQL Chart 引用仓储实现
type ChartReferenceRepository struct {
db *DB
}
// NewChartReferenceRepository 创建 PostgreSQL Chart 引用仓储
func NewChartReferenceRepository(db *DB) repository.ChartReferenceRepository {
return &ChartReferenceRepository{db: db}
}
// Create 创建 Chart 引用
func (r *ChartReferenceRepository) Create(ctx context.Context, chartRef *entity.ChartReference) error {
if chartRef.ID == "" {
chartRef.ID = uuid.New().String()
}
query := `
INSERT INTO chart_references (id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := r.db.conn.ExecContext(ctx, query,
chartRef.ID,
chartRef.WorkspaceID,
chartRef.RegistryID,
chartRef.Repository,
chartRef.ChartName,
chartRef.Description,
chartRef.IsEnabled,
chartRef.CreatedAt,
chartRef.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create chart reference: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Chart 引用
func (r *ChartReferenceRepository) GetByID(ctx context.Context, id string) (*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE id = $1
`
chartRef := &entity.ChartReference{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&chartRef.ID,
&chartRef.WorkspaceID,
&chartRef.RegistryID,
&chartRef.Repository,
&chartRef.ChartName,
&chartRef.Description,
&chartRef.IsEnabled,
&chartRef.CreatedAt,
&chartRef.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrChartReferenceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get chart reference: %w", err)
}
return chartRef, nil
}
// GetByWorkspace 获取 workspace 的所有 Chart 引用
func (r *ChartReferenceRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE workspace_id = $1
ORDER BY chart_name
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list chart references: %w", err)
}
defer rows.Close()
return r.scanChartReferences(rows)
}
// GetByRegistry 获取 registry 的所有 Chart 引用
func (r *ChartReferenceRepository) GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE registry_id = $1
ORDER BY chart_name
`
rows, err := r.db.conn.QueryContext(ctx, query, registryID)
if err != nil {
return nil, fmt.Errorf("failed to list chart references: %w", err)
}
defer rows.Close()
return r.scanChartReferences(rows)
}
// GetByName 根据名称获取 Chart 引用
func (r *ChartReferenceRepository) GetByName(ctx context.Context, workspaceID, chartName string) (*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE workspace_id = $1 AND chart_name = $2
`
chartRef := &entity.ChartReference{}
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, chartName).Scan(
&chartRef.ID,
&chartRef.WorkspaceID,
&chartRef.RegistryID,
&chartRef.Repository,
&chartRef.ChartName,
&chartRef.Description,
&chartRef.IsEnabled,
&chartRef.CreatedAt,
&chartRef.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrChartReferenceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get chart reference: %w", err)
}
return chartRef, nil
}
// Update 更新 Chart 引用
func (r *ChartReferenceRepository) Update(ctx context.Context, chartRef *entity.ChartReference) error {
chartRef.UpdatedAt = time.Now()
query := `
UPDATE chart_references
SET registry_id = $1, repository = $2, chart_name = $3, description = $4, is_enabled = $5, updated_at = $6
WHERE id = $7
`
result, err := r.db.conn.ExecContext(ctx, query,
chartRef.RegistryID,
chartRef.Repository,
chartRef.ChartName,
chartRef.Description,
chartRef.IsEnabled,
chartRef.UpdatedAt,
chartRef.ID,
)
if err != nil {
return fmt.Errorf("failed to update chart reference: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrChartReferenceNotFound
}
return nil
}
// Delete 删除 Chart 引用
func (r *ChartReferenceRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM chart_references WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete chart reference: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrChartReferenceNotFound
}
return nil
}
// List 列出所有 Chart 引用(管理员用)
func (r *ChartReferenceRepository) List(ctx context.Context) ([]*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
ORDER BY workspace_id, chart_name
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list chart references: %w", err)
}
defer rows.Close()
return r.scanChartReferences(rows)
}
// scanChartReferences 扫描多行结果
func (r *ChartReferenceRepository) scanChartReferences(rows *sql.Rows) ([]*entity.ChartReference, error) {
chartRefs := make([]*entity.ChartReference, 0)
for rows.Next() {
chartRef := &entity.ChartReference{}
err := rows.Scan(
&chartRef.ID,
&chartRef.WorkspaceID,
&chartRef.RegistryID,
&chartRef.Repository,
&chartRef.ChartName,
&chartRef.Description,
&chartRef.IsEnabled,
&chartRef.CreatedAt,
&chartRef.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan chart reference: %w", err)
}
chartRefs = append(chartRefs, chartRef)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return chartRefs, nil
}

View File

@ -32,6 +32,11 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
cluster.ID = uuid.New().String()
}
// 设置默认值
if cluster.IsolationMode == "" {
cluster.IsolationMode = entity.IsolationModeNamespace
}
// 加密敏感数据
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
if err != nil {
@ -54,12 +59,14 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
}
query := `
INSERT INTO clusters (id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO clusters (id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`
_, err = r.db.conn.ExecContext(ctx, query,
cluster.ID,
cluster.WorkspaceID,
cluster.OwnerID,
cluster.Name,
cluster.Host,
encryptedCAData,
@ -67,6 +74,9 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData,
encryptedToken,
cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace,
cluster.IsShared,
cluster.CreatedAt,
cluster.UpdatedAt,
)
@ -81,7 +91,7 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
// GetByID 根据 ID 获取集群
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE id = $1
`
@ -91,6 +101,8 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&cluster.ID,
&cluster.WorkspaceID,
&cluster.OwnerID,
&cluster.Name,
&cluster.Host,
&encryptedCAData,
@ -98,6 +110,9 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
&encryptedKeyData,
&encryptedToken,
&cluster.Description,
&cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt,
&cluster.UpdatedAt,
)
@ -110,25 +125,10 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
}
// 解密敏感数据
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData)
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData)
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData)
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken)
return cluster, nil
}
@ -136,7 +136,7 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
// GetByName 根据名称获取集群
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE name = $1
`
@ -146,6 +146,8 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&cluster.ID,
&cluster.WorkspaceID,
&cluster.OwnerID,
&cluster.Name,
&cluster.Host,
&encryptedCAData,
@ -153,6 +155,9 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
&encryptedKeyData,
&encryptedToken,
&cluster.Description,
&cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt,
&cluster.UpdatedAt,
)
@ -165,25 +170,10 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
}
// 解密敏感数据
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
}
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData)
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData)
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData)
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken)
return cluster, nil
}
@ -215,9 +205,10 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
query := `
UPDATE clusters
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
token = $6, description = $7, updated_at = $8
WHERE id = $9
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
token = $6, description = $7, isolation_mode = $8, default_namespace = $9,
is_shared = $10, updated_at = $11
WHERE id = $12
`
result, err := r.db.conn.ExecContext(ctx, query,
@ -228,6 +219,9 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData,
encryptedToken,
cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace,
cluster.IsShared,
cluster.UpdatedAt,
cluster.ID,
)
@ -272,7 +266,7 @@ func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
// List 列出所有集群
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
ORDER BY created_at DESC
`
@ -283,13 +277,59 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
}
defer rows.Close()
return r.scanClusters(rows)
}
// GetByWorkspace 获取 workspace 的所有集群(包括共享集群)
func (r *ClusterRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
query := `
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE workspace_id = $1 OR is_shared = TRUE
ORDER BY is_shared, created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list clusters by workspace: %w", err)
}
defer rows.Close()
return r.scanClusters(rows)
}
// GetShared 获取所有共享集群
func (r *ClusterRepository) GetShared(ctx context.Context) ([]*entity.Cluster, error) {
query := `
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE is_shared = TRUE
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list shared clusters: %w", err)
}
defer rows.Close()
return r.scanClusters(rows)
}
// scanClusters 扫描多行结果
func (r *ClusterRepository) scanClusters(rows *sql.Rows) ([]*entity.Cluster, error) {
clusters := make([]*entity.Cluster, 0)
for rows.Next() {
cluster := &entity.Cluster{}
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
var (
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString
workspaceID, ownerID, defaultNamespace sql.NullString
)
err := rows.Scan(
&cluster.ID,
&workspaceID,
&ownerID,
&cluster.Name,
&cluster.Host,
&encryptedCAData,
@ -297,6 +337,9 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
&encryptedKeyData,
&encryptedToken,
&cluster.Description,
&cluster.IsolationMode,
&defaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt,
&cluster.UpdatedAt,
)
@ -304,25 +347,23 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
return nil, fmt.Errorf("failed to scan cluster: %w", err)
}
// 处理 NULL 值
cluster.WorkspaceID = workspaceID.String
cluster.OwnerID = ownerID.String
cluster.DefaultNamespace = defaultNamespace.String
// 解密敏感数据
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
if encryptedCAData.Valid {
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData.String)
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
if encryptedCertData.Valid {
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData.String)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
if encryptedKeyData.Valid {
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData.String)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
if encryptedToken.Valid {
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken.String)
}
clusters = append(clusters, cluster)

View File

@ -124,6 +124,58 @@ func (db *DB) InitSchema() error {
CREATE INDEX IF NOT EXISTS idx_instances_cluster ON instances(cluster_id);
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_id);
CREATE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
-- Storage Backends 表
CREATE TABLE IF NOT EXISTS storage_backends (
id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36),
owner_id VARCHAR(36),
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
config JSONB NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT FALSE,
is_shared BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_storage_workspace ON storage_backends(workspace_id);
-- Chart References 表
CREATE TABLE IF NOT EXISTS chart_references (
id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36),
registry_id VARCHAR(36),
repository VARCHAR(500) NOT NULL,
chart_name VARCHAR(255) NOT NULL,
description TEXT,
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chart_workspace ON chart_references(workspace_id);
CREATE INDEX IF NOT EXISTS idx_chart_registry ON chart_references(registry_id);
-- Values Templates 表 - 使用复合唯一键替代主键,允许同一模板的多个版本
CREATE TABLE IF NOT EXISTS values_templates (
id VARCHAR(36),
workspace_id VARCHAR(36),
owner_id VARCHAR(36),
chart_reference_id VARCHAR(36),
name VARCHAR(255) NOT NULL,
description TEXT,
values_yaml TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (chart_reference_id, name, version)
);
CREATE INDEX IF NOT EXISTS idx_values_template_chart ON values_templates(chart_reference_id);
CREATE INDEX IF NOT EXISTS idx_values_template_workspace ON values_templates(workspace_id);
`
_, err := db.conn.Exec(schema)

View File

@ -431,3 +431,105 @@ func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, erro
return instances, nil
}
// GetByWorkspace 列出指定工作空间的所有实例(用于配额检查)
func (r *InstanceRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
query := `
SELECT id, cluster_id, workspace_id, owner_id, name, namespace, registry_id, repository, chart, version,
description, values, values_yaml, values_template_id, user_override_yaml,
status, status_reason, last_operation, last_error, revision,
cpu_requested, memory_requested, gpu_requested, gpu_memory_requested,
created_at, updated_at
FROM instances
WHERE workspace_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to get instances by workspace: %w", err)
}
defer rows.Close()
instances := make([]*entity.Instance, 0)
for rows.Next() {
instance := &entity.Instance{}
var (
valuesJSON []byte
statusReason sql.NullString
lastOperation sql.NullString
lastError sql.NullString
valuesTemplateID sql.NullString
userOverrideYAML sql.NullString
memoryRequested sql.NullString
gpuMemoryRequested sql.NullString
)
err := rows.Scan(
&instance.ID,
&instance.ClusterID,
&instance.WorkspaceID,
&instance.OwnerID,
&instance.Name,
&instance.Namespace,
&instance.RegistryID,
&instance.Repository,
&instance.Chart,
&instance.Version,
&instance.Description,
&valuesJSON,
&instance.ValuesYAML,
&valuesTemplateID,
&userOverrideYAML,
&instance.Status,
&statusReason,
&lastOperation,
&lastError,
&instance.Revision,
&instance.CPURequested,
&memoryRequested,
&instance.GPURequested,
&gpuMemoryRequested,
&instance.CreatedAt,
&instance.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan instance: %w", err)
}
if valuesJSON != nil {
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
}
if valuesTemplateID.Valid {
instance.ValuesTemplateID = valuesTemplateID.String
}
if userOverrideYAML.Valid {
instance.UserOverrideYAML = userOverrideYAML.String
}
if statusReason.Valid {
instance.StatusReason = statusReason.String
}
if lastOperation.Valid {
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
}
if lastError.Valid {
instance.LastError = lastError.String
}
if memoryRequested.Valid {
instance.MemoryRequested = memoryRequested.String
}
if gpuMemoryRequested.Valid {
instance.GPUMemoryRequested = gpuMemoryRequested.String
}
instances = append(instances, instance)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return instances, nil
}

View File

@ -0,0 +1,212 @@
package postgres
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"
)
// QuotaRepository PostgreSQL 配额仓储实现
type QuotaRepository struct {
db *DB
}
// NewQuotaRepository 创建 PostgreSQL 配额仓储
func NewQuotaRepository(db *DB) repository.QuotaRepository {
return &QuotaRepository{db: db}
}
// Create 创建配额
func (r *QuotaRepository) Create(ctx context.Context, quota *entity.WorkspaceQuota) error {
if quota.ID == "" {
quota.ID = uuid.New().String()
}
query := `
INSERT INTO workspace_quotas (id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_id, resource_type) DO UPDATE
SET hard_limit = $4, soft_limit = $5, updated_at = $8
`
_, err := r.db.conn.ExecContext(ctx, query,
quota.ID,
quota.WorkspaceID,
quota.ResourceType,
quota.HardLimit,
quota.SoftLimit,
quota.Used,
quota.CreatedAt,
quota.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create quota: %w", err)
}
return nil
}
// GetByID 根据 ID 获取配额
func (r *QuotaRepository) GetByID(ctx context.Context, id string) (*entity.WorkspaceQuota, error) {
query := `
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
FROM workspace_quotas
WHERE id = $1
`
quota := &entity.WorkspaceQuota{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get quota: %w", err)
}
return quota, nil
}
// GetByWorkspaceAndType 根据 workspace 和资源类型获取配额
func (r *QuotaRepository) GetByWorkspaceAndType(ctx context.Context, workspaceID string, resourceType entity.ResourceType) (*entity.WorkspaceQuota, error) {
query := `
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
FROM workspace_quotas
WHERE workspace_id = $1 AND resource_type = $2
`
quota := &entity.WorkspaceQuota{}
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, resourceType).Scan(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get quota: %w", err)
}
return quota, nil
}
// GetByWorkspace 获取 workspace 的所有配额
func (r *QuotaRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.WorkspaceQuota, error) {
query := `
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
FROM workspace_quotas
WHERE workspace_id = $1
ORDER BY resource_type
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list quotas: %w", err)
}
defer rows.Close()
quotas := make([]*entity.WorkspaceQuota, 0)
for rows.Next() {
quota := &entity.WorkspaceQuota{}
err := rows.Scan(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan quota: %w", err)
}
quotas = append(quotas, quota)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return quotas, nil
}
// Update 更新配额
func (r *QuotaRepository) Update(ctx context.Context, quota *entity.WorkspaceQuota) error {
quota.UpdatedAt = time.Now()
query := `
UPDATE workspace_quotas
SET hard_limit = $1, soft_limit = $2, used = $3, updated_at = $4
WHERE id = $5
`
result, err := r.db.conn.ExecContext(ctx, query,
quota.HardLimit,
quota.SoftLimit,
quota.Used,
quota.UpdatedAt,
quota.ID,
)
if err != nil {
return fmt.Errorf("failed to update quota: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return fmt.Errorf("quota not found")
}
return nil
}
// Delete 删除配额
func (r *QuotaRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM workspace_quotas WHERE id = $1`
_, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete quota: %w", err)
}
return nil
}
// DeleteByWorkspace 删除 workspace 的所有配额
func (r *QuotaRepository) DeleteByWorkspace(ctx context.Context, workspaceID string) error {
query := `DELETE FROM workspace_quotas WHERE workspace_id = $1`
_, err := r.db.conn.ExecContext(ctx, query, workspaceID)
if err != nil {
return fmt.Errorf("failed to delete quotas: %w", err)
}
return nil
}

View File

@ -208,7 +208,7 @@ func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
// List 列出所有 Registries
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
query := `
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
FROM registries
ORDER BY created_at DESC
`
@ -222,16 +222,19 @@ func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, erro
registries := make([]*entity.Registry, 0)
for rows.Next() {
registry := &entity.Registry{}
var encryptedPassword string
var encryptedPassword, workspaceID, ownerID sql.NullString
err := rows.Scan(
&registry.ID,
&workspaceID,
&ownerID,
&registry.Name,
&registry.URL,
&registry.Description,
&registry.Username,
&encryptedPassword,
&registry.Insecure,
&registry.IsShared,
&registry.CreatedAt,
&registry.UpdatedAt,
)
@ -239,10 +242,13 @@ func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, erro
return nil, fmt.Errorf("failed to scan registry: %w", err)
}
// 处理 NULL 值
registry.WorkspaceID = workspaceID.String
registry.OwnerID = ownerID.String
// 解密密码
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
if encryptedPassword.Valid {
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
}
registries = append(registries, registry)

View File

@ -0,0 +1,326 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// StorageRepository PostgreSQL 存储后端仓储实现
type StorageRepository struct {
db *DB
}
// NewStorageRepository 创建 PostgreSQL 存储后端仓储
func NewStorageRepository(db *DB) repository.StorageRepository {
return &StorageRepository{db: db}
}
// Create 创建存储后端
func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageBackend) error {
if storage.ID == "" {
storage.ID = uuid.New().String()
}
configJSON, err := json.Marshal(storage.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
query := `
INSERT INTO storage_backends (id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err = r.db.conn.ExecContext(ctx, query,
storage.ID,
storage.WorkspaceID,
storage.OwnerID,
storage.Name,
storage.Type,
configJSON,
storage.Description,
storage.IsDefault,
storage.IsShared,
storage.CreatedAt,
storage.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
return nil
}
// GetByID 根据 ID 获取存储后端
func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE id = $1
`
storage := &entity.StorageBackend{}
var configJSON []byte
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrStorageNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return storage, nil
}
// GetByName 根据名称获取存储后端
func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE workspace_id = $1 AND name = $2
`
storage := &entity.StorageBackend{}
var configJSON []byte
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrStorageNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return storage, nil
}
// GetByWorkspace 获取 workspace 的所有存储后端
func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE workspace_id = $1 OR is_shared = TRUE
ORDER BY is_default DESC, name
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list storage: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// GetShared 获取所有共享存储后端
func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE is_shared = TRUE
ORDER BY name
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list shared storage: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// GetDefault 获取 workspace 的默认存储后端
func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE workspace_id = $1 AND is_default = TRUE
LIMIT 1
`
storage := &entity.StorageBackend{}
var configJSON []byte
err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get default storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return storage, nil
}
// Update 更新存储后端
func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageBackend) error {
storage.UpdatedAt = time.Now()
configJSON, err := json.Marshal(storage.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
query := `
UPDATE storage_backends
SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, updated_at = $7
WHERE id = $8
`
result, err := r.db.conn.ExecContext(ctx, query,
storage.Name,
storage.Type,
configJSON,
storage.Description,
storage.IsDefault,
storage.IsShared,
storage.UpdatedAt,
storage.ID,
)
if err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrStorageNotFound
}
return nil
}
// Delete 删除存储后端
func (r *StorageRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM storage_backends WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrStorageNotFound
}
return nil
}
// List 列出所有存储后端(管理员用)
func (r *StorageRepository) List(ctx context.Context) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
ORDER BY workspace_id, name
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list storage: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// scanStorages 扫描多行结果
func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBackend, error) {
storages := make([]*entity.StorageBackend, 0)
for rows.Next() {
storage := &entity.StorageBackend{}
var configJSON []byte
err := rows.Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
storages = append(storages, storage)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return storages, nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/google/uuid"
@ -27,9 +28,14 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
user.ID = uuid.New().String()
}
// 设置默认值
if user.IsActive {
user.IsActive = true
}
query := `
INSERT INTO users (id, username, password_hash, email, revoked_after, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO users (id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err := r.db.conn.ExecContext(ctx, query,
@ -37,6 +43,10 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
user.Username,
user.PasswordHash,
user.Email,
user.Role,
user.WorkspaceID,
user.IsActive,
user.MustChangePassword,
user.RevokedAfter,
user.CreatedAt,
user.UpdatedAt,
@ -52,22 +62,34 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
// GetByID 根据 ID 获取用户
func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
query := `
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE id = $1
`
user := &entity.User{}
var workspaceID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&workspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
// Handle NULL workspace_id
if workspaceID.Valid {
user.WorkspaceID = workspaceID.String
} else {
user.WorkspaceID = ""
}
if err == sql.ErrNoRows {
return nil, entity.ErrUserNotFound
}
@ -80,30 +102,50 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
// GetByUsername 根据用户名获取用户
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
log.Printf("[DEBUG] GetByUsername called with username: %q", username)
query := `
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE username = $1
`
log.Printf("[DEBUG] Executing query: %s with param: %s", query, username)
user := &entity.User{}
var workspaceID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, username).Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&workspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
// Handle NULL workspace_id
if workspaceID.Valid {
user.WorkspaceID = workspaceID.String
} else {
user.WorkspaceID = ""
}
log.Printf("[DEBUG] Query result - err: %v", err)
if err == sql.ErrNoRows {
log.Printf("[DEBUG] User not found in DB")
return nil, entity.ErrUserNotFound
}
if err != nil {
log.Printf("[DEBUG] Scan error: %v", err)
return nil, fmt.Errorf("failed to get user: %w", err)
}
log.Printf("[DEBUG] Found user: %+v", user)
return user, nil
}
@ -113,14 +155,18 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
query := `
UPDATE users
SET username = $1, password_hash = $2, email = $3, revoked_after = $4, updated_at = $5
WHERE id = $6
SET username = $1, password_hash = $2, email = $3, role = $4, workspace_id = $5, is_active = $6, must_change_password = $7, revoked_after = $8, updated_at = $9
WHERE id = $10
`
result, err := r.db.conn.ExecContext(ctx, query,
user.Username,
user.PasswordHash,
user.Email,
user.Role,
user.WorkspaceID,
user.IsActive,
user.MustChangePassword,
user.RevokedAfter,
user.UpdatedAt,
user.ID,
@ -166,7 +212,7 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error {
// List 列出所有用户
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
query := `
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
ORDER BY created_at DESC
`
@ -185,6 +231,98 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&user.WorkspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return users, nil
}
// ListByWorkspace 列出指定 workspace 的用户
func (r *UserRepository) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error) {
query := `
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE workspace_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list users by workspace: %w", err)
}
defer rows.Close()
users := make([]*entity.User, 0)
for rows.Next() {
user := &entity.User{}
err := rows.Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&user.WorkspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return users, nil
}
// ListActive 仅列出活跃用户
func (r *UserRepository) ListActive(ctx context.Context) ([]*entity.User, error) {
query := `
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE is_active = TRUE
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list active users: %w", err)
}
defer rows.Close()
users := make([]*entity.User, 0)
for rows.Next() {
user := &entity.User{}
err := rows.Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&user.WorkspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,

View File

@ -0,0 +1,287 @@
package postgres
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"
)
// ValuesTemplateRepository PostgreSQL Values 模板仓储实现
type ValuesTemplateRepository struct {
db *DB
}
// NewValuesTemplateRepository 创建 PostgreSQL Values 模板仓储
func NewValuesTemplateRepository(db *DB) repository.ValuesTemplateRepository {
return &ValuesTemplateRepository{db: db}
}
// Create 创建 Values 模板
func (r *ValuesTemplateRepository) Create(ctx context.Context, template *entity.ValuesTemplate) error {
if template.ID == "" {
template.ID = uuid.New().String()
}
query := `
INSERT INTO values_templates (id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err := r.db.conn.ExecContext(ctx, query,
template.ID,
template.WorkspaceID,
template.OwnerID,
template.ChartReferenceID,
template.Name,
template.Description,
template.ValuesYAML,
template.Version,
template.IsDefault,
template.CreatedAt,
template.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create values template: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Values 模板
func (r *ValuesTemplateRepository) GetByID(ctx context.Context, id string) (*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE id = $1
`
template := &entity.ValuesTemplate{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&template.ID,
&template.WorkspaceID,
&template.OwnerID,
&template.ChartReferenceID,
&template.Name,
&template.Description,
&template.ValuesYAML,
&template.Version,
&template.IsDefault,
&template.CreatedAt,
&template.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get values template: %w", err)
}
return template, nil
}
// GetByWorkspace 获取 workspace 的所有 Values 模板
func (r *ValuesTemplateRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE workspace_id = $1
ORDER BY chart_reference_id, name, version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list values templates: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// GetByChartReference 获取 Chart Reference 的所有 Values 模板
func (r *ValuesTemplateRepository) GetByChartReference(ctx context.Context, chartRefID string) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE chart_reference_id = $1
ORDER BY name, version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, chartRefID)
if err != nil {
return nil, fmt.Errorf("failed to list values templates: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// GetByName 根据名称获取 Values 模板(获取最新版本)
func (r *ValuesTemplateRepository) GetByName(ctx context.Context, workspaceID, chartRefID, name string) (*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE workspace_id = $1 AND chart_reference_id = $2 AND name = $3
ORDER BY version DESC
LIMIT 1
`
template := &entity.ValuesTemplate{}
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, chartRefID, name).Scan(
&template.ID,
&template.WorkspaceID,
&template.OwnerID,
&template.ChartReferenceID,
&template.Name,
&template.Description,
&template.ValuesYAML,
&template.Version,
&template.IsDefault,
&template.CreatedAt,
&template.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get values template: %w", err)
}
return template, nil
}
// GetHistory 获取模板的版本历史
func (r *ValuesTemplateRepository) GetHistory(ctx context.Context, chartRefID, name string) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE chart_reference_id = $1 AND name = $2
ORDER BY version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, chartRefID, name)
if err != nil {
return nil, fmt.Errorf("failed to get values template history: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// Update 更新 Values 模板(自动递增版本)
func (r *ValuesTemplateRepository) Update(ctx context.Context, template *entity.ValuesTemplate) error {
// 获取当前最大版本号
var maxVersion int
err := r.db.conn.QueryRowContext(ctx,
"SELECT COALESCE(MAX(version), 0) FROM values_templates WHERE chart_reference_id = $1 AND name = $2",
template.ChartReferenceID, template.Name,
).Scan(&maxVersion)
if err != nil {
return fmt.Errorf("failed to get max version: %w", err)
}
// 生成新 ID 用于新版本
newID := uuid.New().String()
template.Version = maxVersion + 1
template.UpdatedAt = time.Now()
template.CreatedAt = time.Now() // 新版本的创建时间
query := `
INSERT INTO values_templates (id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err = r.db.conn.ExecContext(ctx, query,
newID,
template.WorkspaceID,
template.OwnerID,
template.ChartReferenceID,
template.Name,
template.Description,
template.ValuesYAML,
template.Version,
template.IsDefault,
template.CreatedAt,
template.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to update values template: %w", err)
}
return nil
}
// Delete 删除 Values 模板
func (r *ValuesTemplateRepository) Delete(ctx context.Context, id string) error {
// 获取模板信息
template, err := r.GetByID(ctx, id)
if err != nil {
return err
}
// 删除该名称的所有版本
query := `DELETE FROM values_templates WHERE chart_reference_id = $1 AND name = $2`
_, err = r.db.conn.ExecContext(ctx, query, template.ChartReferenceID, template.Name)
if err != nil {
return fmt.Errorf("failed to delete values template: %w", err)
}
return nil
}
// List 列出所有 Values 模板(管理员用)
func (r *ValuesTemplateRepository) List(ctx context.Context) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
ORDER BY workspace_id, chart_reference_id, name, version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list values templates: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// scanValuesTemplates 扫描多行结果
func (r *ValuesTemplateRepository) scanValuesTemplates(rows *sql.Rows) ([]*entity.ValuesTemplate, error) {
templates := make([]*entity.ValuesTemplate, 0)
for rows.Next() {
template := &entity.ValuesTemplate{}
err := rows.Scan(
&template.ID,
&template.WorkspaceID,
&template.OwnerID,
&template.ChartReferenceID,
&template.Name,
&template.Description,
&template.ValuesYAML,
&template.Version,
&template.IsDefault,
&template.CreatedAt,
&template.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan values template: %w", err)
}
templates = append(templates, template)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return templates, nil
}

View File

@ -0,0 +1,197 @@
package postgres
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"
)
// WorkspaceRepository PostgreSQL Workspace 仓储实现
type WorkspaceRepository struct {
db *DB
}
// NewWorkspaceRepository 创建 PostgreSQL Workspace 仓储
func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository {
return &WorkspaceRepository{db: db}
}
// Create 创建 Workspace
func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error {
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
query := `
INSERT INTO workspaces (id, name, description, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.conn.ExecContext(ctx, query,
workspace.ID,
workspace.Name,
workspace.Description,
workspace.CreatedBy,
workspace.CreatedAt,
workspace.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create workspace: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Workspace
func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
WHERE id = $1
`
workspace := &entity.Workspace{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrWorkspaceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
return workspace, nil
}
// GetByName 根据名称获取 Workspace
func (r *WorkspaceRepository) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
WHERE name = $1
`
workspace := &entity.Workspace{}
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrWorkspaceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
return workspace, nil
}
// Update 更新 Workspace
func (r *WorkspaceRepository) Update(ctx context.Context, workspace *entity.Workspace) error {
workspace.UpdatedAt = time.Now()
query := `
UPDATE workspaces
SET name = $1, description = $2, updated_at = $3
WHERE id = $4
`
result, err := r.db.conn.ExecContext(ctx, query,
workspace.Name,
workspace.Description,
workspace.UpdatedAt,
workspace.ID,
)
if err != nil {
return fmt.Errorf("failed to update workspace: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrWorkspaceNotFound
}
return nil
}
// Delete 删除 Workspace
func (r *WorkspaceRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM workspaces WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete workspace: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrWorkspaceNotFound
}
return nil
}
// List 列出所有 Workspace
func (r *WorkspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list workspaces: %w", err)
}
defer rows.Close()
workspaces := make([]*entity.Workspace, 0)
for rows.Next() {
workspace := &entity.Workspace{}
err := rows.Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan workspace: %w", err)
}
workspaces = append(workspaces, workspace)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return workspaces, nil
}

View File

@ -0,0 +1,90 @@
package entity
import (
"encoding/json"
"time"
)
// AuditAction 审计操作类型
type AuditAction string
const (
AuditActionCreate AuditAction = "create"
AuditActionUpdate AuditAction = "update"
AuditActionDelete AuditAction = "delete"
AuditActionDeploy AuditAction = "deploy"
AuditActionScale AuditAction = "scale"
AuditActionLogin AuditAction = "login"
AuditActionLogout AuditAction = "logout"
AuditActionChangePassword AuditAction = "change_password"
)
// AuditResourceType 审计资源类型
type AuditResourceType string
const (
AuditResourceUser AuditResourceType = "user"
AuditResourceWorkspace AuditResourceType = "workspace"
AuditResourceQuota AuditResourceType = "quota"
AuditResourceCluster AuditResourceType = "cluster"
AuditResourceRegistry AuditResourceType = "registry"
AuditResourceInstance AuditResourceType = "instance"
AuditResourceStorage AuditResourceType = "storage"
AuditResourceTemplate AuditResourceType = "template"
)
// AuditLog 审计日志实体
type AuditLog struct {
ID string
WorkspaceID string
UserID string
Action AuditAction
ResourceType AuditResourceType
ResourceID string
ResourceName string
Details map[string]interface{}
IPAddress string
UserAgent string
CreatedAt time.Time
}
// NewAuditLog 创建新审计日志
func NewAuditLog(workspaceID, userID string, action AuditAction, resourceType AuditResourceType) *AuditLog {
now := time.Now()
return &AuditLog{
WorkspaceID: workspaceID,
UserID: userID,
Action: action,
ResourceType: resourceType,
CreatedAt: now,
}
}
// SetResource 设置关联资源
func (a *AuditLog) SetResource(resourceID, resourceName string) {
a.ResourceID = resourceID
a.ResourceName = resourceName
}
// SetDetails 设置详细信息
func (a *AuditLog) SetDetails(details map[string]interface{}) {
a.Details = details
}
// SetClientInfo 设置客户端信息
func (a *AuditLog) SetClientInfo(ipAddress, userAgent string) {
a.IPAddress = ipAddress
a.UserAgent = userAgent
}
// DetailsJSON 将详情转为 JSON 字符串
func (a *AuditLog) DetailsJSON() (string, error) {
if a.Details == nil {
return "{}", nil
}
data, err := json.Marshal(a.Details)
if err != nil {
return "", err
}
return string(data), nil
}

View File

@ -0,0 +1,41 @@
package entity
import (
"time"
)
// ChartReference Chart 引用实体
type ChartReference struct {
ID string
WorkspaceID string
RegistryID string
Repository string
ChartName string
Description string
IsEnabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewChartReference 创建新 Chart 引用
func NewChartReference(workspaceID, registryID, repository, chartName, description string) *ChartReference {
now := time.Now()
return &ChartReference{
WorkspaceID: workspaceID,
RegistryID: registryID,
Repository: repository,
ChartName: chartName,
Description: description,
IsEnabled: true,
CreatedAt: now,
UpdatedAt: now,
}
}
// Validate 验证 Chart 引用数据
func (c *ChartReference) Validate() error {
if c.Repository == "" {
return ErrInvalidChart
}
return nil
}

View File

@ -4,28 +4,49 @@ import (
"time"
)
// IsolationMode 集群隔离模式
type IsolationMode string
const (
IsolationModeNamespace IsolationMode = "namespace" // 共享集群模式,多 workspace 使用不同 namespace
IsolationModeCluster IsolationMode = "cluster" // 私有集群模式,每个 workspace 独立集群
)
// Cluster Kubernetes 集群领域实体
type Cluster struct {
ID string
Name string
Host string // Kubernetes API Server URL
CAData string // Base64 encoded CA certificate
CertData string // Base64 encoded client certificate
KeyData string // Base64 encoded client key
Token string // Bearer token (alternative to cert auth)
Description string
CreatedAt time.Time
UpdatedAt time.Time
ID string
WorkspaceID string // 所属 workspaceNULL 表示全局共享
OwnerID string // 创建者用户 ID
Name string
Host string // Kubernetes API Server URL
CAData string // Base64 encoded CA certificate
CertData string // Base64 encoded client certificate
KeyData string // Base64 encoded client key
Token string // Bearer token (alternative to cert auth)
Description string
// 隔离模式
IsolationMode IsolationMode // 'namespace' | 'cluster'
DefaultNamespace string // 当 isolation_mode=namespace 时的默认 namespace 前缀
IsShared bool // 是否为共享集群admin 创建供多 workspace 使用)
CreatedAt time.Time
UpdatedAt time.Time
}
// NewCluster 创建新集群
func NewCluster(name, host string) *Cluster {
func NewCluster(workspaceID, ownerID, name, host string) *Cluster {
now := time.Now()
return &Cluster{
Name: name,
Host: host,
CreatedAt: now,
UpdatedAt: now,
WorkspaceID: workspaceID,
OwnerID: ownerID,
Name: name,
Host: host,
IsolationMode: IsolationModeNamespace, // 默认 namespace 隔离模式
DefaultNamespace: workspaceID, // 默认使用 workspace ID 作为 namespace 前缀
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
}
}
@ -63,11 +84,35 @@ func (c *Cluster) Validate() error {
if c.Host == "" {
return ErrInvalidClusterHost
}
// 必须有认证方式:证书或 Token
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
return ErrInvalidClusterAuth
// 检查是否有 kubeconfig 格式(完整的 kubeconfig 在 CAData 中)
hasKubeconfig := len(c.CAData) > 100 && (c.CAData[:11] == "apiVersion:" || c.CAData[:5] == "kind:")
// 认证方式证书、Token、kubeconfig 或空(使用本地 kubeconfig
hasCertAuth := c.CertData != "" && c.KeyData != ""
hasToken := c.Token != ""
hasNoAuth := c.CertData == "" && c.KeyData == "" && c.Token == ""
// 如果有 kubeconfig 格式,或有证书,或有 token或没有凭证依赖 TestConnection 使用本地 kubeconfig都是有效的
if hasKubeconfig || hasCertAuth || hasToken || hasNoAuth {
return nil
}
return nil
return ErrInvalidClusterAuth
}
// GetNamespace 获取部署用的 namespace
// namespace 隔离模式: {workspace_id}-{instance_name} 或 {default_namespace}-{username}
// cluster 隔离模式: 使用 workspace 的默认 namespace
func (c *Cluster) GetNamespace(workspaceName, instanceName string) string {
if c.IsolationMode == IsolationModeCluster {
return c.DefaultNamespace
}
// namespace 隔离模式
if c.DefaultNamespace != "" {
return c.DefaultNamespace + "-" + instanceName
}
return workspaceName + "-" + instanceName
}
// GetKubeConfig 生成 kubeconfig 内容

View File

@ -37,4 +37,32 @@ var (
ErrArtifactNotFound = errors.New("artifact not found")
ErrRepositoryNotFound = errors.New("repository not found")
ErrValuesSchemaNotFound = errors.New("values schema not found")
ErrValuesNotFound = errors.New("values not found")
// Workspace errors
ErrInvalidWorkspaceName = errors.New("invalid workspace name")
ErrWorkspaceNotFound = errors.New("workspace not found")
ErrWorkspaceExists = errors.New("workspace already exists")
// Quota errors
ErrQuotaExceeded = errors.New("quota exceeded")
ErrInvalidQuota = errors.New("invalid quota")
// Storage errors
ErrInvalidStorageName = errors.New("invalid storage name")
ErrStorageNotFound = errors.New("storage not found")
ErrStorageExists = errors.New("storage already exists")
// Chart Reference errors
ErrInvalidChartReferenceName = errors.New("invalid chart reference name")
ErrChartReferenceNotFound = errors.New("chart reference not found")
ErrChartReferenceExists = errors.New("chart reference already exists")
// Template errors
ErrInvalidTemplateName = errors.New("invalid template name")
ErrTemplateNotFound = errors.New("template not found")
ErrTemplateExists = errors.New("template already exists")
// Permission errors
ErrPermissionDenied = errors.New("permission denied")
)

View File

@ -1,7 +1,9 @@
package entity
import (
"strings"
"time"
"unicode"
)
// InstanceStatus 实例状态
@ -33,43 +35,65 @@ const (
// Instance Helm 应用实例领域实体
type Instance struct {
ID string
ClusterID string
Name string // Helm Release Name
Namespace string
RegistryID string
Repository string // OCI Repository (e.g., charts/app)
Chart string // Chart Name
Version string // Chart Version
Description string
Values map[string]interface{} // Helm Values (JSON)
ValuesYAML string // Helm Values (YAML format)
Status InstanceStatus
StatusReason string
LastOperation InstanceOperation
LastError string
Revision int // Helm Release Revision
CreatedAt time.Time
UpdatedAt time.Time
ID string
WorkspaceID string // 所属 workspace
OwnerID string // 创建者用户 ID
ClusterID string
RegistryID string
ChartReferenceID string // 引用的 Chart 引用
ValuesTemplateID string // 使用的 Values 模板
Name string // Helm Release Name
Namespace string
Repository string // OCI Repository (e.g., charts/app)
Chart string // Chart Name
Version string // Chart Version
Description string
Values map[string]interface{} // Helm Values (JSON)
ValuesYAML string // Helm Values (YAML format)
UserOverrideYAML string // 用户额外覆盖的配置
Status InstanceStatus
StatusReason string
LastOperation InstanceOperation
LastError string
Revision int // Helm Release Revision
// 资源使用统计Helm 安装时从集群获取并更新)
CPURequested float64 // CPU 请求量 (cores)
MemoryRequested string // 内存请求量 (e.g., "2Gi")
GPURequested float64 // GPU 请求量 (cards)
GPUMemoryRequested string // GPU 内存请求量 (e.g., "16Gi")
CreatedAt time.Time
UpdatedAt time.Time
}
// NewInstance 创建新实例
func NewInstance(clusterID, name, namespace, registryID, repository, chart, version string) *Instance {
func NewInstance(workspaceID, ownerID, clusterID, registryID, chartReferenceID, valuesTemplateID, name, namespace, repository, chart, version string) *Instance {
now := time.Now()
return &Instance{
ClusterID: clusterID,
Name: name,
Namespace: namespace,
RegistryID: registryID,
Repository: repository,
Chart: chart,
Version: version,
Status: StatusPending,
StatusReason: "Pending install",
LastOperation: OperationInstall,
Revision: 1,
CreatedAt: now,
UpdatedAt: now,
WorkspaceID: workspaceID,
OwnerID: ownerID,
ClusterID: clusterID,
RegistryID: registryID,
ChartReferenceID: chartReferenceID,
ValuesTemplateID: valuesTemplateID,
Name: name,
Namespace: namespace,
Repository: repository,
Chart: chart,
Version: version,
Status: StatusPending,
StatusReason: "Pending install",
LastOperation: OperationInstall,
Revision: 1,
CPURequested: 0,
MemoryRequested: "0Mi",
GPURequested: 0,
GPUMemoryRequested: "0Mi",
CreatedAt: now,
UpdatedAt: now,
}
}
@ -154,13 +178,43 @@ func (i *Instance) Upgrade(version string, values map[string]interface{}) {
i.BeginOperation(OperationUpgrade, "Pending upgrade")
}
// ValidateReleaseName 验证 Helm Release 名称是否符合 RFC 1123 DNS 子域名规范
// Helm release 名称必须:
// - 只能包含小写字母a-z、数字0-9和连字符-
// - 不能以连字符开头或结尾
// - 长度不超过 53 个字符
func ValidateReleaseName(name string) error {
if name == "" {
return ErrInvalidInstanceName
}
// 检查长度RFC 1123 DNS 子域名最大长度为 63但 Helm 限制为 53
if len(name) > 53 {
return ErrInvalidInstanceName
}
// 不能以连字符开头或结尾
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return ErrInvalidInstanceName
}
// 只能包含小写字母、数字和连字符
for _, r := range name {
if !(unicode.IsLower(r) || unicode.IsDigit(r) || r == '-') {
return ErrInvalidInstanceName
}
}
return nil
}
// Validate 验证实例配置
func (i *Instance) Validate() error {
if i.ClusterID == "" {
return ErrInvalidClusterID
}
if i.Name == "" {
return ErrInvalidInstanceName
if err := ValidateReleaseName(i.Name); err != nil {
return err
}
if i.Namespace == "" {
return ErrInvalidNamespace

View File

@ -0,0 +1,79 @@
package entity
import (
"time"
)
// ResourceType 资源类型
type ResourceType string
const (
ResourceCPU ResourceType = "cpu"
ResourceGPU ResourceType = "gpu"
ResourceGPUMemory ResourceType = "gpu_memory"
)
// WorkspaceQuota 工作空间配额实体
type WorkspaceQuota struct {
ID string
WorkspaceID string
ResourceType ResourceType
HardLimit float64 // 硬限制0表示无限制
SoftLimit float64 // 软限制(警告阈值)
Used float64 // 当前使用量
CreatedAt time.Time
UpdatedAt time.Time
}
// NewWorkspaceQuota 创建新配额
func NewWorkspaceQuota(workspaceID string, resourceType ResourceType, hardLimit, softLimit float64) *WorkspaceQuota {
now := time.Now()
return &WorkspaceQuota{
WorkspaceID: workspaceID,
ResourceType: resourceType,
HardLimit: hardLimit,
SoftLimit: softLimit,
Used: 0,
CreatedAt: now,
UpdatedAt: now,
}
}
// CanAllocate 检查是否可以分配指定资源量
func (q *WorkspaceQuota) CanAllocate(amount float64) bool {
if q.HardLimit == 0 {
return true // 无限制
}
return q.Used+amount <= q.HardLimit
}
// Allocate 分配资源
func (q *WorkspaceQuota) Allocate(amount float64) {
q.Used += amount
q.UpdatedAt = time.Now()
}
// Release 释放资源
func (q *WorkspaceQuota) Release(amount float64) {
q.Used -= amount
if q.Used < 0 {
q.Used = 0
}
q.UpdatedAt = time.Now()
}
// IsOverLimit 检查是否超过硬限制
func (q *WorkspaceQuota) IsOverLimit() bool {
if q.HardLimit == 0 {
return false
}
return q.Used > q.HardLimit
}
// IsOverSoftLimit 检查是否超过软限制
func (q *WorkspaceQuota) IsOverSoftLimit() bool {
if q.SoftLimit == 0 {
return false
}
return q.Used > q.SoftLimit
}

View File

@ -7,24 +7,30 @@ import (
// Registry OCI Registry 领域实体
type Registry struct {
ID string
WorkspaceID string // 所属 workspaceNULL 表示全局共享
OwnerID string // 创建者用户 ID
Name string
URL string
Description string
Username string
Password string
Insecure bool // 是否跳过 TLS 验证
Insecure bool // 是否跳过 TLS 验证
IsShared bool // 是否为共享 Registryadmin 创建供多 workspace 使用)
CreatedAt time.Time
UpdatedAt time.Time
}
// NewRegistry 创建新 Registry
func NewRegistry(name, url string) *Registry {
func NewRegistry(workspaceID, ownerID, name, url string) *Registry {
now := time.Now()
return &Registry{
Name: name,
URL: url,
CreatedAt: now,
UpdatedAt: now,
WorkspaceID: workspaceID,
OwnerID: ownerID,
Name: name,
URL: url,
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
}
}

View File

@ -0,0 +1,98 @@
package entity
import (
"encoding/json"
"time"
)
// StorageType 存储类型
type StorageType string
const (
StorageTypeNFS StorageType = "nfs"
StorageTypePV StorageType = "pv"
StorageTypeHostPath StorageType = "hostPath"
)
// StorageConfig 存储配置
type StorageConfig struct {
NFS *NFSConfig `json:"nfs,omitempty"`
PV *PVConfig `json:"pv,omitempty"`
HostPath *HostPathConfig `json:"hostPath,omitempty"`
}
// NFSConfig NFS 配置
type NFSConfig struct {
Server string `json:"server"`
Path string `json:"path"`
}
// PVConfig PV 配置
type PVConfig struct {
StorageClassName string `json:"storageClassName"`
Capacity string `json:"capacity"`
AccessModes []string `json:"accessModes"`
}
// HostPathConfig HostPath 配置
type HostPathConfig struct {
Path string `json:"path"`
}
// StorageBackend 存储后端实体
type StorageBackend struct {
ID string
WorkspaceID string
OwnerID string
Name string
Type StorageType
Config StorageConfig
Description string
IsDefault bool
IsShared bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewStorageBackend 创建新存储后端
func NewStorageBackend(workspaceID, ownerID, name string, storageType StorageType, config StorageConfig) *StorageBackend {
now := time.Now()
return &StorageBackend{
WorkspaceID: workspaceID,
OwnerID: ownerID,
Name: name,
Type: storageType,
Config: config,
IsDefault: false,
IsShared: false,
CreatedAt: now,
UpdatedAt: now,
}
}
// Validate 验证存储后端数据
func (s *StorageBackend) Validate() error {
if s.Name == "" {
return ErrInvalidStorageName
}
if s.Type == "" {
return ErrInvalidStorageName
}
return nil
}
// ConfigJSON 将配置转为 JSON 字符串
func (s *StorageBackend) ConfigJSON() (string, error) {
data, err := json.Marshal(s.Config)
if err != nil {
return "", err
}
return string(data), nil
}
// ParseConfigJSON 从 JSON 解析配置
func ParseConfigJSON(jsonStr string) (*StorageConfig, error) {
var config StorageConfig
err := json.Unmarshal([]byte(jsonStr), &config)
return &config, err
}

View File

@ -4,30 +4,58 @@ import (
"time"
)
// UserRole 用户角色
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleUser UserRole = "user"
)
// User 用户领域实体
type User struct {
ID string
Username string
PasswordHash string
Email string
RevokedAfter time.Time // 全局 Token 撤销时间
CreatedAt time.Time
UpdatedAt time.Time
ID string
Username string
PasswordHash string
Email string
Role UserRole // 用户角色: admin, user
WorkspaceID string // 所属工作空间admin 为空表示全局
IsActive bool // 账户是否激活
MustChangePassword bool // 首次登录必须修改密码
RevokedAfter time.Time // 全局 Token 撤销时间
CreatedAt time.Time
UpdatedAt time.Time
}
// NewUser 创建新用户
func NewUser(username, passwordHash, email string) *User {
now := time.Now()
return &User{
Username: username,
PasswordHash: passwordHash,
Email: email,
RevokedAfter: time.Unix(0, 0), // 初始值1970-01-01
CreatedAt: now,
UpdatedAt: now,
Username: username,
PasswordHash: passwordHash,
Email: email,
Role: RoleUser, // 默认普通用户
IsActive: true,
MustChangePassword: true, // 首次登录必须修改密码
RevokedAfter: time.Unix(0, 0), // 初始值1970-01-01
CreatedAt: now,
UpdatedAt: now,
}
}
// IsAdmin 判断是否为管理员
func (u *User) IsAdmin() bool {
return u.Role == RoleAdmin
}
// CanAccessWorkspace 检查是否可以访问指定工作空间
func (u *User) CanAccessWorkspace(workspaceID string) bool {
if u.IsAdmin() {
return true // Admin 可以访问所有工作空间
}
return u.WorkspaceID == workspaceID
}
// UpdatePassword 更新密码(会触发全局登出)
func (u *User) UpdatePassword(newPasswordHash string) {
u.PasswordHash = newPasswordHash

View File

@ -0,0 +1,83 @@
package entity
import (
"time"
)
// ValuesTemplate Values 模板实体(带版本管理)
type ValuesTemplate struct {
ID string
WorkspaceID string
OwnerID string
ChartReferenceID string
Name string
Description string
ValuesYAML string
Version int // 模板版本号
IsDefault bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewValuesTemplate 创建新 Values 模板
func NewValuesTemplate(workspaceID, ownerID, chartReferenceID, name, valuesYAML string) *ValuesTemplate {
now := time.Now()
return &ValuesTemplate{
WorkspaceID: workspaceID,
OwnerID: ownerID,
ChartReferenceID: chartReferenceID,
Name: name,
ValuesYAML: valuesYAML,
Version: 1,
IsDefault: false,
CreatedAt: now,
UpdatedAt: now,
}
}
// Validate 验证 Values 模板数据
func (v *ValuesTemplate) Validate() error {
if v.Name == "" {
return ErrInvalidTemplateName
}
if v.ValuesYAML == "" {
return ErrInvalidTemplateName
}
return nil
}
// IncrementVersion 递增版本号
func (v *ValuesTemplate) IncrementVersion() {
v.Version++
v.UpdatedAt = time.Now()
}
// UserConfigOverride 用户配置覆盖实体
type UserConfigOverride struct {
ID string
WorkspaceID string
UserID string
TargetType string // 'storage', 'template', 'global'
TargetID string
Config map[string]interface{}
Priority int
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
}
// NewUserConfigOverride 创建新用户配置覆盖
func NewUserConfigOverride(workspaceID, userID, targetType, targetID string, config map[string]interface{}) *UserConfigOverride {
now := time.Now()
return &UserConfigOverride{
WorkspaceID: workspaceID,
UserID: userID,
TargetType: targetType,
TargetID: targetID,
Config: config,
Priority: 0,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}
}

View File

@ -0,0 +1,35 @@
package entity
import (
"time"
)
// Workspace 工作空间实体
type Workspace struct {
ID string
Name string
Description string
CreatedBy string // 创建者用户 ID
CreatedAt time.Time
UpdatedAt time.Time
}
// NewWorkspace 创建新工作空间
func NewWorkspace(name, description, createdBy string) *Workspace {
now := time.Now()
return &Workspace{
Name: name,
Description: description,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
}
}
// Validate 验证工作空间数据
func (w *Workspace) Validate() error {
if w.Name == "" {
return ErrInvalidWorkspaceName
}
return nil
}

View File

@ -0,0 +1,27 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// AuditLogRepository 审计日志仓储接口
type AuditLogRepository interface {
// Create 创建审计日志
Create(ctx context.Context, log *entity.AuditLog) error
// GetByWorkspace 获取 workspace 的审计日志
GetByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error)
// GetByUser 获取用户的审计日志
GetByUser(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error)
// GetByResource 获取资源的审计日志
GetByResource(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error)
// List 列出审计日志(分页)
List(ctx context.Context, limit, offset int) ([]*entity.AuditLog, error)
// DeleteByWorkspace 删除 workspace 的审计日志
DeleteByWorkspace(ctx context.Context, workspaceID string) error
}

View File

@ -0,0 +1,33 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// ChartReferenceRepository Chart 引用仓储接口
type ChartReferenceRepository interface {
// Create 创建 Chart 引用
Create(ctx context.Context, chartRef *entity.ChartReference) error
// GetByID 根据 ID 获取 Chart 引用
GetByID(ctx context.Context, id string) (*entity.ChartReference, error)
// GetByWorkspace 获取 workspace 的所有 Chart 引用
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error)
// GetByRegistry 获取 registry 的所有 Chart 引用
GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error)
// GetByName 根据名称获取 Chart 引用
GetByName(ctx context.Context, workspaceID, chartName string) (*entity.ChartReference, error)
// Update 更新 Chart 引用
Update(ctx context.Context, chartRef *entity.ChartReference) error
// Delete 删除 Chart 引用
Delete(ctx context.Context, id string) error
// List 列出所有 Chart 引用(管理员用)
List(ctx context.Context) ([]*entity.ChartReference, error)
}

View File

@ -9,20 +9,26 @@ import (
type ClusterRepository interface {
// Create 创建集群
Create(ctx context.Context, cluster *entity.Cluster) error
// GetByID 根据 ID 获取集群
GetByID(ctx context.Context, id string) (*entity.Cluster, error)
// GetByName 根据名称获取集群
GetByName(ctx context.Context, name string) (*entity.Cluster, error)
// Update 更新集群
Update(ctx context.Context, cluster *entity.Cluster) error
// Delete 删除集群
Delete(ctx context.Context, id string) error
// List 列出所有集群
List(ctx context.Context) ([]*entity.Cluster, error)
// GetByWorkspace 获取 workspace 的所有集群(包括共享集群)
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error)
// GetShared 获取所有共享集群
GetShared(ctx context.Context) ([]*entity.Cluster, error)
}

View File

@ -9,23 +9,26 @@ import (
type InstanceRepository interface {
// Create 创建实例
Create(ctx context.Context, instance *entity.Instance) error
// GetByID 根据 ID 获取实例
GetByID(ctx context.Context, id string) (*entity.Instance, error)
// GetByClusterAndName 根据集群 ID 和名称获取实例
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
// Update 更新实例
Update(ctx context.Context, instance *entity.Instance) error
// Delete 删除实例
Delete(ctx context.Context, id string) error
// ListByCluster 列出指定集群的所有实例
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
// List 列出所有实例
List(ctx context.Context) ([]*entity.Instance, error)
// GetByWorkspace 列出指定工作空间的所有实例(用于配额检查)
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error)
}

View File

@ -19,7 +19,10 @@ type OCIClient interface {
// GetValuesSchema 获取 Helm Chart 的 values schema
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
// GetValues 获取 Helm Chart 的 values.yaml
GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
// PullArtifact 下载 artifact 到本地
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error

View File

@ -0,0 +1,30 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// QuotaRepository 配额仓储接口
type QuotaRepository interface {
// Create 创建配额
Create(ctx context.Context, quota *entity.WorkspaceQuota) error
// GetByID 根据 ID 获取配额
GetByID(ctx context.Context, id string) (*entity.WorkspaceQuota, error)
// GetByWorkspaceAndType 根据 workspace 和资源类型获取配额
GetByWorkspaceAndType(ctx context.Context, workspaceID string, resourceType entity.ResourceType) (*entity.WorkspaceQuota, error)
// GetByWorkspace 获取 workspace 的所有配额
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.WorkspaceQuota, error)
// Update 更新配额
Update(ctx context.Context, quota *entity.WorkspaceQuota) error
// Delete 删除配额
Delete(ctx context.Context, id string) error
// DeleteByWorkspace 删除 workspace 的所有配额
DeleteByWorkspace(ctx context.Context, workspaceID string) error
}

View File

@ -0,0 +1,36 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// StorageRepository 存储后端仓储接口
type StorageRepository interface {
// Create 创建存储后端
Create(ctx context.Context, storage *entity.StorageBackend) error
// GetByID 根据 ID 获取存储后端
GetByID(ctx context.Context, id string) (*entity.StorageBackend, error)
// GetByName 根据名称获取存储后端
GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error)
// GetByWorkspace 获取 workspace 的所有存储后端
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error)
// GetShared 获取所有共享存储后端
GetShared(ctx context.Context) ([]*entity.StorageBackend, error)
// GetDefault 获取 workspace 的默认存储后端
GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error)
// Update 更新存储后端
Update(ctx context.Context, storage *entity.StorageBackend) error
// Delete 删除存储后端
Delete(ctx context.Context, id string) error
// List 列出所有存储后端(管理员用)
List(ctx context.Context) ([]*entity.StorageBackend, error)
}

View File

@ -9,20 +9,26 @@ import (
type UserRepository interface {
// Create 创建用户
Create(ctx context.Context, user *entity.User) error
// GetByID 根据 ID 获取用户
GetByID(ctx context.Context, id string) (*entity.User, error)
// GetByUsername 根据用户名获取用户
GetByUsername(ctx context.Context, username string) (*entity.User, error)
// Update 更新用户
Update(ctx context.Context, user *entity.User) error
// Delete 删除用户
Delete(ctx context.Context, id string) error
// List 列出所有用户
List(ctx context.Context) ([]*entity.User, error)
// ListByWorkspace 列出指定 workspace 的用户
ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error)
// ListActive 仅列出活跃用户
ListActive(ctx context.Context) ([]*entity.User, error)
}

View File

@ -0,0 +1,36 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// ValuesTemplateRepository Values 模板仓储接口
type ValuesTemplateRepository interface {
// Create 创建 Values 模板
Create(ctx context.Context, template *entity.ValuesTemplate) error
// GetByID 根据 ID 获取 Values 模板
GetByID(ctx context.Context, id string) (*entity.ValuesTemplate, error)
// GetByWorkspace 获取 workspace 的所有 Values 模板
GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ValuesTemplate, error)
// GetByChartReference 获取 Chart Reference 的所有 Values 模板
GetByChartReference(ctx context.Context, chartRefID string) ([]*entity.ValuesTemplate, error)
// GetByName 根据名称获取 Values 模板
GetByName(ctx context.Context, workspaceID, chartRefID, name string) (*entity.ValuesTemplate, error)
// GetHistory 获取模板的版本历史
GetHistory(ctx context.Context, chartRefID, name string) ([]*entity.ValuesTemplate, error)
// Update 更新 Values 模板(自动递增版本)
Update(ctx context.Context, template *entity.ValuesTemplate) error
// Delete 删除 Values 模板
Delete(ctx context.Context, id string) error
// List 列出所有 Values 模板(管理员用)
List(ctx context.Context) ([]*entity.ValuesTemplate, error)
}

View File

@ -0,0 +1,27 @@
package repository
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// WorkspaceRepository Workspace 仓储接口
type WorkspaceRepository interface {
// Create 创建 Workspace
Create(ctx context.Context, workspace *entity.Workspace) error
// GetByID 根据 ID 获取 Workspace
GetByID(ctx context.Context, id string) (*entity.Workspace, error)
// GetByName 根据名称获取 Workspace
GetByName(ctx context.Context, name string) (*entity.Workspace, error)
// Update 更新 Workspace
Update(ctx context.Context, workspace *entity.Workspace) error
// Delete 删除 Workspace
Delete(ctx context.Context, id string) error
// List 列出所有 Workspace
List(ctx context.Context) ([]*entity.Workspace, error)
}

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

View File

@ -26,98 +26,106 @@ func NewJWTManager(secretKey string) *JWTManager {
// Claims JWT Claims
type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
WorkspaceID string `json:"workspace_id"`
jwt.RegisteredClaims
}
// Generate 生成 Access Token 和 Refresh Token
func (m *JWTManager) Generate(userID, username string) (accessToken, refreshToken string, err error) {
func (m *JWTManager) Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error) {
// 生成 Access Token
accessClaims := &Claims{
UserID: userID,
Username: username,
UserID: userID,
Username: username,
Role: role,
WorkspaceID: workspaceID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessToken, err = accessTokenObj.SignedString([]byte(m.secretKey))
if err != nil {
return "", "", fmt.Errorf("failed to sign access token: %w", err)
}
// 生成 Refresh Token
refreshClaims := &Claims{
UserID: userID,
Username: username,
UserID: userID,
Username: username,
Role: role,
WorkspaceID: workspaceID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshToken, err = refreshTokenObj.SignedString([]byte(m.secretKey))
if err != nil {
return "", "", fmt.Errorf("failed to sign refresh token: %w", err)
}
return accessToken, refreshToken, nil
}
// Verify 验证 Token
func (m *JWTManager) Verify(tokenString string) (userID, username string, err error) {
userID, username, _, err = m.VerifyWithIssuedAt(tokenString)
return userID, username, err
func (m *JWTManager) Verify(tokenString string) (userID, username, role, workspaceID string, err error) {
userID, username, role, workspaceID, _, err = m.VerifyWithIssuedAt(tokenString)
return userID, username, role, workspaceID, err
}
// VerifyWithIssuedAt 验证 Token 并返回签发时间
func (m *JWTManager) VerifyWithIssuedAt(tokenString string) (userID, username string, issuedAt int64, err error) {
func (m *JWTManager) VerifyWithIssuedAt(tokenString string) (userID, username, role, workspaceID string, issuedAt int64, err error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(m.secretKey), nil
})
if err != nil {
return "", "", 0, fmt.Errorf("failed to parse token: %w", err)
return "", "", "", "", 0, fmt.Errorf("failed to parse token: %w", err)
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims.UserID, claims.Username, claims.IssuedAt.Unix(), nil
return claims.UserID, claims.Username, claims.Role, claims.WorkspaceID, claims.IssuedAt.Unix(), nil
}
return "", "", 0, fmt.Errorf("invalid token")
return "", "", "", "", 0, fmt.Errorf("invalid token")
}
// Refresh 刷新 Token
func (m *JWTManager) Refresh(refreshToken string) (string, error) {
// 验证 Refresh Token
userID, username, err := m.Verify(refreshToken)
userID, username, role, workspaceID, err := m.Verify(refreshToken)
if err != nil {
return "", fmt.Errorf("invalid refresh token: %w", err)
}
// 生成新的 Access Token
accessClaims := &Claims{
UserID: userID,
Username: username,
UserID: userID,
Username: username,
Role: role,
WorkspaceID: workspaceID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
newAccessToken, err := accessTokenObj.SignedString([]byte(m.secretKey))
if err != nil {
return "", fmt.Errorf("failed to sign new access token: %w", err)
}
return newAccessToken, nil
}

BIN
backend/ocdp-backend Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More