352 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}