350 lines
8.9 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 job
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"hyapp-admin-server/internal/cache"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"gorm.io/gorm"
)
const userExportJobType = "user-export"
type HandlerFunc func(ctx context.Context, job *model.AdminJob) (string, error)
// ArtifactHandlerFunc 供需要生成可下载文件的业务任务使用artifactPath 必须位于 runner 配置的导出目录内。
type ArtifactHandlerFunc func(ctx context.Context, job *model.AdminJob) (resultJSON string, artifactPath string, err error)
type Option func(*Runner)
type Store interface {
LeaseNextJob(workerID string, lease time.Duration, maxAttempts int) (*model.AdminJob, error)
RenewJobLease(id uint, workerID string, lease time.Duration) error
CompleteJobWithArtifact(id uint, workerID string, resultJSON string, artifactPath string) error
FailJobAttempt(id uint, workerID string, message string) error
ReleaseJobLease(id uint, workerID string) error
ExportUsers(options repository.ListOptions) ([]model.User, error)
}
type Locker interface {
TryLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, cache.UnlockFunc, error)
RefreshLock(ctx context.Context, key string, owner string, ttl time.Duration) (bool, error)
}
type Runner struct {
artifactDir string
interval time.Duration
leaseTTL time.Duration
locker Locker
handlers map[string]HandlerFunc
artifactHandlers map[string]ArtifactHandlerFunc
maxAttempts int
store Store
workerID string
closeOnce sync.Once
done chan struct{}
stop chan struct{}
}
func WithHandler(jobType string, handler HandlerFunc) Option {
return func(r *Runner) {
jobType = strings.TrimSpace(jobType)
if jobType == "" || handler == nil {
return
}
r.handlers[jobType] = handler
}
}
// WithArtifactHandler 注册带文件产物的任务,避免业务模块绕过统一 job lease、重试和下载校验。
func WithArtifactHandler(jobType string, handler ArtifactHandlerFunc) Option {
return func(r *Runner) {
jobType = strings.TrimSpace(jobType)
if jobType == "" || handler == nil {
return
}
r.artifactHandlers[jobType] = handler
}
}
func NewRunner(store Store, locker Locker, cfg config.Config, opts ...Option) *Runner {
interval := cfg.Jobs.WorkerInterval
if interval <= 0 {
interval = 5 * time.Second
}
leaseTTL := cfg.Jobs.LeaseTTL
if leaseTTL <= 0 {
leaseTTL = 2 * time.Minute
}
maxAttempts := cfg.Jobs.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = 3
}
artifactDir := strings.TrimSpace(cfg.Jobs.ArtifactDir)
if artifactDir == "" {
artifactDir = "storage/exports"
}
workerID := strings.TrimSpace(cfg.NodeID)
if workerID == "" {
workerID = "admin-job-worker"
}
runner := &Runner{
artifactDir: artifactDir,
interval: interval,
leaseTTL: leaseTTL,
locker: locker,
handlers: make(map[string]HandlerFunc),
artifactHandlers: make(map[string]ArtifactHandlerFunc),
maxAttempts: maxAttempts,
store: store,
workerID: workerID,
done: make(chan struct{}),
stop: make(chan struct{}),
}
for _, opt := range opts {
if opt != nil {
opt(runner)
}
}
return runner
}
func (r *Runner) Start() {
go r.loop()
}
func (r *Runner) Close() {
r.closeOnce.Do(func() {
close(r.stop)
<-r.done
})
}
func (r *Runner) Status() map[string]any {
return map[string]any{
"artifactDir": r.artifactDir,
"interval": r.interval.String(),
"leaseTTL": r.leaseTTL.String(),
"maxAttempts": r.maxAttempts,
"workerID": r.workerID,
}
}
func (r *Runner) loop() {
defer close(r.done)
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
r.runOnce(context.Background())
for {
select {
case <-ticker.C:
r.runOnce(context.Background())
case <-r.stop:
return
}
}
}
func (r *Runner) runOnce(ctx context.Context) {
for {
job, err := r.store.LeaseNextJob(r.workerID, r.leaseTTL, r.maxAttempts)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
slog.Error("job_find_pending_failed", "worker_id", r.workerID, "error", err.Error())
return
}
locked, unlock, err := r.lock(ctx, job)
if err != nil {
slog.Error("job_lock_failed", "worker_id", r.workerID, "job_id", job.ID, "error", err.Error())
_ = r.store.ReleaseJobLease(job.ID, r.workerID)
return
}
if !locked {
_ = r.store.ReleaseJobLease(job.ID, r.workerID)
return
}
err = r.handleWithHeartbeat(ctx, job)
if unlock != nil {
_ = unlock(ctx)
}
if err != nil {
_ = r.store.FailJobAttempt(job.ID, r.workerID, err.Error())
}
}
}
func (r *Runner) handleWithHeartbeat(ctx context.Context, job *model.AdminJob) error {
jobCtx, cancel := context.WithCancel(ctx)
defer cancel()
heartbeatResult := make(chan error, 1)
go r.heartbeat(jobCtx, cancel, job, heartbeatResult)
handleErr := r.handle(jobCtx, job)
cancel()
heartbeatErr := <-heartbeatResult
if handleErr != nil {
return handleErr
}
return heartbeatErr
}
func (r *Runner) heartbeat(ctx context.Context, cancel context.CancelFunc, job *model.AdminJob, result chan<- error) {
interval := r.leaseTTL / 3
if interval < 100*time.Millisecond {
interval = 100 * time.Millisecond
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
result <- nil
return
case <-ticker.C:
if err := r.store.RenewJobLease(job.ID, r.workerID, r.leaseTTL); err != nil {
cancel()
result <- fmt.Errorf("renew job lease: %w", err)
return
}
if r.locker != nil {
refreshed, err := r.locker.RefreshLock(ctx, fmt.Sprintf("jobs:%d", job.ID), r.workerID, r.leaseTTL)
if err != nil {
cancel()
result <- fmt.Errorf("refresh job lock: %w", err)
return
}
if !refreshed {
cancel()
result <- errors.New("job distributed lock is no longer owned by worker")
return
}
}
}
}
}
func (r *Runner) handle(ctx context.Context, job *model.AdminJob) error {
switch job.Type {
case userExportJobType:
return r.exportAdminUsers(ctx, job)
default:
if handler := r.artifactHandlers[strings.TrimSpace(job.Type)]; handler != nil {
resultJSON, artifactPath, err := handler(ctx, job)
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, resultJSON, artifactPath)
}
handler := r.handlers[strings.TrimSpace(job.Type)]
if handler == nil {
return fmt.Errorf("unsupported job type: %s", job.Type)
}
resultJSON, err := handler(ctx, job)
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, resultJSON, "")
}
}
func (r *Runner) exportAdminUsers(_ context.Context, job *model.AdminJob) error {
var options repository.ListOptions
if strings.TrimSpace(job.PayloadJSON) != "" {
if err := json.Unmarshal([]byte(job.PayloadJSON), &options); err != nil {
return err
}
}
users, err := r.store.ExportUsers(options)
if err != nil {
return err
}
artifactPath, err := r.writeUserExport(job, users)
if err != nil {
return err
}
result, err := json.Marshal(map[string]any{
"count": len(users),
"downloadPath": fmt.Sprintf("/api/v1/jobs/%d/artifact", job.ID),
})
if err != nil {
return err
}
return r.store.CompleteJobWithArtifact(job.ID, r.workerID, string(result), artifactPath)
}
func (r *Runner) lock(ctx context.Context, job *model.AdminJob) (bool, cache.UnlockFunc, error) {
if r.locker == nil {
return true, func(context.Context) error { return nil }, nil
}
return r.locker.TryLock(ctx, fmt.Sprintf("jobs:%d", job.ID), r.workerID, r.leaseTTL)
}
func (r *Runner) writeUserExport(job *model.AdminJob, users []model.User) (string, error) {
if err := os.MkdirAll(r.artifactDir, 0o755); err != nil {
return "", err
}
fileName := fmt.Sprintf("user-export-%d.csv", job.ID)
artifactPath := filepath.Join(r.artifactDir, fileName)
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
_ = writer.Write([]string{"ID", "账号", "姓名", "角色", "团队", "状态", "MFA", "最后登录"})
for _, user := range users {
lastLogin := ""
if user.LastLoginAtMS != nil {
lastLogin = time.UnixMilli(*user.LastLoginAtMS).UTC().Format("2006-01-02 15:04:05")
}
_ = writer.Write([]string{
fmt.Sprintf("%d", user.ID),
user.Username,
user.Name,
strings.Join(roleNames(user.Roles), "、"),
user.Team,
user.Status,
boolText(user.MFAEnabled),
lastLogin,
})
}
writer.Flush()
if err := writer.Error(); err != nil {
return "", err
}
if err := os.WriteFile(artifactPath, buf.Bytes(), 0o644); err != nil {
return "", err
}
return fileName, nil
}
func roleNames(roles []model.Role) []string {
names := make([]string, 0, len(roles))
for _, role := range roles {
names = append(names, role.Name)
}
return names
}
func boolText(value bool) string {
if value {
return "已开启"
}
return "未开启"
}