feat: 添加数据模型和存储层

- model: JobTemplate、SubmitJobRequest、JobHistoryQuery 等模型定义

- store: NewGormDB MySQL 连接池,使用 zap 日志替代 GORM 默认日志

- store: TemplateStore CRUD 操作,支持 GORM AutoMigrate

- NewGormDB 接受 gormLevel 参数,由上层传入配置值

- 完整 TDD 测试覆盖

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-10 08:39:30 +08:00
parent f7a21ee455
commit fbfd5c5f42
9 changed files with 506 additions and 0 deletions

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS job_templates;

View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS job_templates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
script TEXT NOT NULL,
partition VARCHAR(255),
qos VARCHAR(255),
cpus INT UNSIGNED,
memory VARCHAR(50),
time_limit VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

44
internal/store/mysql.go Normal file
View File

@@ -0,0 +1,44 @@
package store
import (
"fmt"
"time"
"gcy_hpc_server/internal/logger"
"gcy_hpc_server/internal/model"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// NewGormDB opens a GORM MySQL connection with sensible defaults.
func NewGormDB(dsn string, zapLogger *zap.Logger, gormLevel string) (*gorm.DB, error) {
gormCfg := &gorm.Config{
Logger: logger.NewGormLogger(zapLogger, gormLevel),
}
db, err := gorm.Open(mysql.Open(dsn), gormCfg)
if err != nil {
return nil, fmt.Errorf("failed to open gorm mysql: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping mysql: %w", err)
}
return db, nil
}
// AutoMigrate runs GORM auto-migration for all models.
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(&model.JobTemplate{})
}

View File

@@ -0,0 +1,14 @@
package store
import (
"testing"
"go.uber.org/zap"
)
func TestNewGormDBInvalidDSN(t *testing.T) {
_, err := NewGormDB("invalid:dsn@tcp(localhost:99999)/nonexistent?parseTime=true", zap.NewNop(), "warn")
if err == nil {
t.Fatal("expected error for invalid DSN, got nil")
}
}

View File

@@ -0,0 +1,113 @@
package store
import (
"context"
"errors"
"gorm.io/gorm"
"gcy_hpc_server/internal/model"
)
// TemplateStore provides CRUD operations for job templates via GORM.
type TemplateStore struct {
db *gorm.DB
}
// NewTemplateStore creates a new TemplateStore.
func NewTemplateStore(db *gorm.DB) *TemplateStore {
return &TemplateStore{db: db}
}
// List returns a paginated list of job templates and the total count.
func (s *TemplateStore) List(ctx context.Context, page, pageSize int) ([]model.JobTemplate, int, error) {
var templates []model.JobTemplate
var total int64
if err := s.db.WithContext(ctx).Model(&model.JobTemplate{}).Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := s.db.WithContext(ctx).Order("id DESC").Limit(pageSize).Offset(offset).Find(&templates).Error; err != nil {
return nil, 0, err
}
return templates, int(total), nil
}
// GetByID returns a single job template by ID. Returns nil, nil when not found.
func (s *TemplateStore) GetByID(ctx context.Context, id int64) (*model.JobTemplate, error) {
var t model.JobTemplate
err := s.db.WithContext(ctx).First(&t, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
// Create inserts a new job template and returns the generated ID.
func (s *TemplateStore) Create(ctx context.Context, req *model.CreateTemplateRequest) (int64, error) {
t := &model.JobTemplate{
Name: req.Name,
Description: req.Description,
Script: req.Script,
Partition: req.Partition,
QOS: req.QOS,
CPUs: req.CPUs,
Memory: req.Memory,
TimeLimit: req.TimeLimit,
}
if err := s.db.WithContext(ctx).Create(t).Error; err != nil {
return 0, err
}
return t.ID, nil
}
// Update modifies an existing job template. Only non-empty/non-zero fields are updated.
func (s *TemplateStore) Update(ctx context.Context, id int64, req *model.UpdateTemplateRequest) error {
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.Script != "" {
updates["script"] = req.Script
}
if req.Partition != "" {
updates["partition"] = req.Partition
}
if req.QOS != "" {
updates["qos"] = req.QOS
}
if req.CPUs > 0 {
updates["cpus"] = req.CPUs
}
if req.Memory != "" {
updates["memory"] = req.Memory
}
if req.TimeLimit != "" {
updates["time_limit"] = req.TimeLimit
}
if len(updates) == 0 {
return nil // nothing to update
}
result := s.db.WithContext(ctx).Model(&model.JobTemplate{}).Where("id = ?", id).Updates(updates)
return result.Error
}
// Delete removes a job template by ID. Idempotent — returns nil even if the row doesn't exist.
func (s *TemplateStore) Delete(ctx context.Context, id int64) error {
result := s.db.WithContext(ctx).Delete(&model.JobTemplate{}, id)
if result.Error != nil {
return result.Error
}
return nil
}

View File

@@ -0,0 +1,205 @@
package store
import (
"context"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gcy_hpc_server/internal/model"
)
func newTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.JobTemplate{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return db
}
func TestTemplateStore_List(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
s.Create(context.Background(), &model.CreateTemplateRequest{Name: "job-1", Script: "echo 1"})
s.Create(context.Background(), &model.CreateTemplateRequest{Name: "job-2", Script: "echo 2"})
templates, total, err := s.List(context.Background(), 1, 10)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if total != 2 {
t.Errorf("total = %d, want 2", total)
}
if len(templates) != 2 {
t.Fatalf("len(templates) = %d, want 2", len(templates))
}
// DESC order, so job-2 is first
if templates[0].Name != "job-2" {
t.Errorf("templates[0].Name = %q, want %q", templates[0].Name, "job-2")
}
}
func TestTemplateStore_List_Page2(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
for i := 0; i < 15; i++ {
s.Create(context.Background(), &model.CreateTemplateRequest{
Name: "job-" + string(rune('A'+i)), Script: "echo",
})
}
templates, total, err := s.List(context.Background(), 2, 10)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if total != 15 {
t.Errorf("total = %d, want 15", total)
}
if len(templates) != 5 {
t.Fatalf("len(templates) = %d, want 5", len(templates))
}
}
func TestTemplateStore_GetByID(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
id, _ := s.Create(context.Background(), &model.CreateTemplateRequest{
Name: "test-job", Script: "echo hi", Partition: "batch", QOS: "normal", CPUs: 2, Memory: "4G",
})
tpl, err := s.GetByID(context.Background(), id)
if err != nil {
t.Fatalf("GetByID() error = %v", err)
}
if tpl == nil {
t.Fatal("GetByID() returned nil")
}
if tpl.Name != "test-job" {
t.Errorf("Name = %q, want %q", tpl.Name, "test-job")
}
if tpl.CPUs != 2 {
t.Errorf("CPUs = %d, want 2", tpl.CPUs)
}
}
func TestTemplateStore_GetByID_NotFound(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
tpl, err := s.GetByID(context.Background(), 999)
if err != nil {
t.Fatalf("GetByID() error = %v, want nil", err)
}
if tpl != nil {
t.Fatal("GetByID() should return nil for not found")
}
}
func TestTemplateStore_Create(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
id, err := s.Create(context.Background(), &model.CreateTemplateRequest{
Name: "new-job", Script: "echo", Partition: "gpu",
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if id == 0 {
t.Fatal("Create() returned id=0")
}
}
func TestTemplateStore_Update(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
id, _ := s.Create(context.Background(), &model.CreateTemplateRequest{
Name: "old", Script: "echo",
})
err := s.Update(context.Background(), id, &model.UpdateTemplateRequest{
Name: "updated",
Script: "echo new",
CPUs: 8,
})
if err != nil {
t.Fatalf("Update() error = %v", err)
}
tpl, _ := s.GetByID(context.Background(), id)
if tpl.Name != "updated" {
t.Errorf("Name = %q, want %q", tpl.Name, "updated")
}
if tpl.CPUs != 8 {
t.Errorf("CPUs = %d, want 8", tpl.CPUs)
}
}
func TestTemplateStore_Update_Partial(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
id, _ := s.Create(context.Background(), &model.CreateTemplateRequest{
Name: "original", Script: "echo orig", Partition: "batch",
})
err := s.Update(context.Background(), id, &model.UpdateTemplateRequest{
Name: "renamed",
})
if err != nil {
t.Fatalf("Update() error = %v", err)
}
tpl, _ := s.GetByID(context.Background(), id)
if tpl.Name != "renamed" {
t.Errorf("Name = %q, want %q", tpl.Name, "renamed")
}
// Script and Partition should be unchanged
if tpl.Script != "echo orig" {
t.Errorf("Script = %q, want %q", tpl.Script, "echo orig")
}
if tpl.Partition != "batch" {
t.Errorf("Partition = %q, want %q", tpl.Partition, "batch")
}
}
func TestTemplateStore_Delete(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
id, _ := s.Create(context.Background(), &model.CreateTemplateRequest{
Name: "to-delete", Script: "echo",
})
err := s.Delete(context.Background(), id)
if err != nil {
t.Fatalf("Delete() error = %v", err)
}
tpl, _ := s.GetByID(context.Background(), id)
if tpl != nil {
t.Fatal("Delete() did not remove the record")
}
}
func TestTemplateStore_Delete_NotFound(t *testing.T) {
db := newTestDB(t)
s := NewTemplateStore(db)
err := s.Delete(context.Background(), 999)
if err != nil {
t.Fatalf("Delete() should not error for non-existent record, got: %v", err)
}
}