352 lines
10 KiB
Go
352 lines
10 KiB
Go
package repository
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/model"
|
||
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
func (s *Store) ListRoleDataScopes(roleID uint) ([]model.DataScope, error) {
|
||
var scopes []model.DataScope
|
||
err := s.db.Where("role_id = ?", roleID).Order("scope_type ASC, scope_value ASC").Find(&scopes).Error
|
||
return scopes, err
|
||
}
|
||
|
||
func (s *Store) DataAccessForUser(userID uint) (DataAccess, error) {
|
||
if userID == 0 {
|
||
return DataAccess{All: true}, nil
|
||
}
|
||
|
||
var user model.User
|
||
if err := s.db.Preload("Roles").First(&user, userID).Error; err != nil {
|
||
return DataAccess{}, err
|
||
}
|
||
roleIDs := make([]uint, 0, len(user.Roles))
|
||
for _, role := range user.Roles {
|
||
roleIDs = append(roleIDs, role.ID)
|
||
if role.Code == "platform-admin" {
|
||
return DataAccess{UserID: userID, All: true}, nil
|
||
}
|
||
}
|
||
if len(roleIDs) == 0 {
|
||
return DataAccess{UserID: userID, All: true}, nil
|
||
}
|
||
|
||
var scopes []model.DataScope
|
||
if err := s.db.Where("role_id IN ?", roleIDs).Find(&scopes).Error; err != nil {
|
||
return DataAccess{}, err
|
||
}
|
||
if len(scopes) == 0 {
|
||
return DataAccess{UserID: userID, All: true}, nil
|
||
}
|
||
|
||
access := DataAccess{UserID: userID, Restricted: true}
|
||
teamSet := map[string]struct{}{}
|
||
for _, scope := range scopes {
|
||
scopeType := strings.TrimSpace(scope.ScopeType)
|
||
scopeValue := strings.TrimSpace(scope.ScopeValue)
|
||
switch scopeType {
|
||
case "all":
|
||
return DataAccess{UserID: userID, All: true}, nil
|
||
case "self":
|
||
access.SelfOnly = true
|
||
case "team":
|
||
if scopeValue == "self" && strings.TrimSpace(user.Team) != "" {
|
||
teamSet[user.Team] = struct{}{}
|
||
continue
|
||
}
|
||
if scopeValue != "" {
|
||
teamSet[scopeValue] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
for team := range teamSet {
|
||
access.Teams = append(access.Teams, team)
|
||
}
|
||
return access, nil
|
||
}
|
||
|
||
func (s *Store) ReplaceRoleDataScopes(roleID uint, scopes []model.DataScope) error {
|
||
if roleID == 0 {
|
||
return errors.New("role id is required")
|
||
}
|
||
for i := range scopes {
|
||
scopes[i].RoleID = roleID
|
||
scopes[i].ScopeType = strings.TrimSpace(scopes[i].ScopeType)
|
||
scopes[i].ScopeValue = strings.TrimSpace(scopes[i].ScopeValue)
|
||
if scopes[i].ScopeType == "" || scopes[i].ScopeValue == "" {
|
||
return errors.New("scope type and value are required")
|
||
}
|
||
}
|
||
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
var role model.Role
|
||
if err := tx.First(&role, roleID).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Where("role_id = ?", roleID).Delete(&model.DataScope{}).Error; err != nil {
|
||
return err
|
||
}
|
||
if len(scopes) == 0 {
|
||
return nil
|
||
}
|
||
return tx.Create(&scopes).Error
|
||
})
|
||
}
|
||
|
||
func (s *Store) CreateJob(job *model.AdminJob) error {
|
||
if strings.TrimSpace(job.Type) == "" {
|
||
return errors.New("job type is required")
|
||
}
|
||
job.Type = strings.TrimSpace(job.Type)
|
||
job.Status = model.JobStatusPending
|
||
if job.MaxAttempts <= 0 {
|
||
job.MaxAttempts = 3
|
||
}
|
||
return s.db.Create(job).Error
|
||
}
|
||
|
||
func (s *Store) ListJobs(options ListOptions) ([]model.AdminJob, int64, error) {
|
||
page, pageSize := normalizePage(options.Page, options.PageSize)
|
||
query := s.db.Model(&model.AdminJob{})
|
||
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
|
||
like := "%" + keyword + "%"
|
||
query = query.Where("type LIKE ? OR created_by_name LIKE ? OR error LIKE ?", like, like, like)
|
||
}
|
||
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
|
||
query = query.Where("status = ?", status)
|
||
}
|
||
|
||
var total int64
|
||
if err := query.Count(&total).Error; err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
var jobs []model.AdminJob
|
||
err := query.Order("created_at_ms DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&jobs).Error
|
||
return jobs, total, err
|
||
}
|
||
|
||
func (s *Store) ListActiveJobsByType(jobType string) ([]model.AdminJob, error) {
|
||
var jobs []model.AdminJob
|
||
err := s.db.Where("type = ? AND status IN ?", strings.TrimSpace(jobType), []string{model.JobStatusPending, model.JobStatusRunning}).
|
||
Order("created_at_ms ASC, id ASC").
|
||
Find(&jobs).Error
|
||
return jobs, err
|
||
}
|
||
|
||
func (s *Store) LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error) {
|
||
if strings.TrimSpace(workerID) == "" {
|
||
return nil, errors.New("worker id is required")
|
||
}
|
||
if lease <= 0 {
|
||
return nil, errors.New("lease must be greater than 0")
|
||
}
|
||
if maxAttempts <= 0 {
|
||
maxAttempts = 3
|
||
}
|
||
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
var leased model.AdminJob
|
||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
var job model.AdminJob
|
||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("(status = ? OR (status = ? AND locked_until_ms IS NOT NULL AND locked_until_ms < ?)) AND attempt < max_attempts", model.JobStatusPending, model.JobStatusRunning, nowMS).
|
||
Order("created_at_ms ASC, id ASC").
|
||
First(&job).Error
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if job.MaxAttempts <= 0 {
|
||
job.MaxAttempts = maxAttempts
|
||
}
|
||
updates := map[string]any{
|
||
"status": model.JobStatusRunning,
|
||
"attempt": job.Attempt + 1,
|
||
"max_attempts": job.MaxAttempts,
|
||
"locked_by": workerID,
|
||
"locked_until_ms": nowMS + lease.Milliseconds(),
|
||
"started_at_ms": firstMS(job.StartedAtMS, nowMS),
|
||
"error": "",
|
||
}
|
||
if err := tx.Model(&model.AdminJob{}).Where("id = ?", job.ID).Updates(updates).Error; err != nil {
|
||
return err
|
||
}
|
||
if err := tx.First(&leased, job.ID).Error; err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, gorm.ErrRecordNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &leased, nil
|
||
}
|
||
|
||
func (s *Store) CompleteJobWithArtifact(id uint, workerID string, resultJSON string, artifactPath string) error {
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
result := s.db.Model(&model.AdminJob{}).
|
||
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
|
||
Updates(map[string]any{
|
||
"status": model.JobStatusSucceeded,
|
||
"result_json": resultJSON,
|
||
"artifact_path": artifactPath,
|
||
"locked_by": "",
|
||
"locked_until_ms": nil,
|
||
"finished_at_ms": &nowMS,
|
||
})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("job lease is no longer owned by worker")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RenewJobLease 延长仍属于 worker 的 running 任务;RowsAffected=0 说明任务已完成或租约已经被其他实例接管。
|
||
func (s *Store) RenewJobLease(id uint, workerID string, lease time.Duration) error {
|
||
if strings.TrimSpace(workerID) == "" || lease <= 0 {
|
||
return errors.New("worker id and positive lease are required")
|
||
}
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
result := s.db.Model(&model.AdminJob{}).
|
||
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
|
||
Update("locked_until_ms", nowMS+lease.Milliseconds())
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
var job model.AdminJob
|
||
if err := s.db.Select("status", "locked_by").First(&job, id).Error; err != nil {
|
||
return err
|
||
}
|
||
// handler 完成提交与 heartbeat tick 可能恰好相邻;终态无需续租,不能把成功任务误报为失败。
|
||
if job.Status != model.JobStatusRunning {
|
||
return nil
|
||
}
|
||
return errors.New("job lease is no longer owned by worker")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type CountryCodeAdminReferenceCounts struct {
|
||
Banners int64 `json:"banners"`
|
||
SplashScreens int64 `json:"splashScreens"`
|
||
}
|
||
|
||
func (s *Store) RenameCountryCodeAdminReferences(appCode string, oldCountryCode string, newCountryCode string, nowMS int64) (CountryCodeAdminReferenceCounts, error) {
|
||
var counts CountryCodeAdminReferenceCounts
|
||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||
result := tx.Model(&model.AppBanner{}).
|
||
Where("app_code = ? AND country_code = ?", strings.TrimSpace(appCode), strings.TrimSpace(oldCountryCode)).
|
||
Updates(map[string]any{"country_code": strings.TrimSpace(newCountryCode), "updated_at_ms": nowMS})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
counts.Banners = result.RowsAffected
|
||
|
||
result = tx.Model(&model.AppSplashScreen{}).
|
||
Where("app_code = ? AND country_code = ?", strings.TrimSpace(appCode), strings.TrimSpace(oldCountryCode)).
|
||
Updates(map[string]any{"country_code": strings.TrimSpace(newCountryCode), "updated_at_ms": nowMS})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
counts.SplashScreens = result.RowsAffected
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return CountryCodeAdminReferenceCounts{}, err
|
||
}
|
||
return counts, nil
|
||
}
|
||
|
||
func (s *Store) FailJobAttempt(id uint, workerID string, message string) error {
|
||
var job model.AdminJob
|
||
if err := s.db.Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).First(&job).Error; err != nil {
|
||
return err
|
||
}
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
status := model.JobStatusPending
|
||
finishedAt := any(nil)
|
||
if job.Attempt >= job.MaxAttempts {
|
||
status = model.JobStatusFailed
|
||
finishedAt = &nowMS
|
||
}
|
||
result := s.db.Model(&model.AdminJob{}).
|
||
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
|
||
Updates(map[string]any{
|
||
"status": status,
|
||
"error": message,
|
||
"locked_by": "",
|
||
"locked_until_ms": nil,
|
||
"finished_at_ms": finishedAt,
|
||
})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("job lease is no longer owned by worker")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) ReleaseJobLease(id uint, workerID string) error {
|
||
result := s.db.Model(&model.AdminJob{}).
|
||
Where("id = ? AND status = ? AND locked_by = ?", id, model.JobStatusRunning, strings.TrimSpace(workerID)).
|
||
Updates(map[string]any{
|
||
"status": model.JobStatusPending,
|
||
"locked_by": "",
|
||
"locked_until_ms": nil,
|
||
})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("job lease is no longer owned by worker")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) CancelJob(id uint) error {
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
result := s.db.Model(&model.AdminJob{}).
|
||
Where("id = ? AND status IN ?", id, []string{model.JobStatusPending, model.JobStatusRunning}).
|
||
Updates(map[string]any{
|
||
"status": model.JobStatusCanceled,
|
||
"locked_by": "",
|
||
"locked_until_ms": nil,
|
||
"finished_at_ms": &nowMS,
|
||
})
|
||
if result.Error != nil {
|
||
return result.Error
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return errors.New("job cannot be canceled")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Store) FindJobByID(id uint) (*model.AdminJob, error) {
|
||
var job model.AdminJob
|
||
if err := s.db.First(&job, id).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &job, nil
|
||
}
|
||
|
||
func firstMS(value *int64, fallback int64) *int64 {
|
||
if value != nil {
|
||
return value
|
||
}
|
||
return &fallback
|
||
}
|