服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
301 lines
6.0 KiB
Go
301 lines
6.0 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Job struct {
|
|
Name string
|
|
Interval time.Duration
|
|
Jitter time.Duration
|
|
Run func(context.Context) error
|
|
}
|
|
|
|
type JobSnapshot struct {
|
|
Name string `json:"name"`
|
|
IntervalMS int64 `json:"interval_ms"`
|
|
JitterMS int64 `json:"jitter_ms,omitempty"`
|
|
Running bool `json:"running"`
|
|
Runs int64 `json:"runs"`
|
|
Errors int64 `json:"errors"`
|
|
LastStarted *time.Time `json:"last_started,omitempty"`
|
|
LastEnded *time.Time `json:"last_ended,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
NextRun *time.Time `json:"next_run,omitempty"`
|
|
}
|
|
|
|
type Scheduler struct {
|
|
name string
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
jobs map[string]*jobState
|
|
running bool
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
now func() time.Time
|
|
jitter func(time.Duration) time.Duration
|
|
}
|
|
|
|
type jobState struct {
|
|
job Job
|
|
|
|
running bool
|
|
runs int64
|
|
errors int64
|
|
lastStarted time.Time
|
|
lastEnded time.Time
|
|
lastError string
|
|
nextRun time.Time
|
|
}
|
|
|
|
func New(name string, logger *slog.Logger) *Scheduler {
|
|
if name == "" {
|
|
name = "scheduler"
|
|
}
|
|
return &Scheduler{
|
|
name: name,
|
|
logger: logger,
|
|
jobs: make(map[string]*jobState),
|
|
now: time.Now,
|
|
jitter: randomJitter,
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) Name() string {
|
|
if s == nil {
|
|
return "scheduler"
|
|
}
|
|
return s.name
|
|
}
|
|
|
|
func (s *Scheduler) Add(job Job) error {
|
|
if s == nil {
|
|
return fmt.Errorf("scheduler is nil")
|
|
}
|
|
job.Name = strings.TrimSpace(job.Name)
|
|
if job.Name == "" {
|
|
return fmt.Errorf("scheduler job name is required")
|
|
}
|
|
if job.Interval <= 0 {
|
|
return fmt.Errorf("scheduler job %q interval must be positive", job.Name)
|
|
}
|
|
if job.Jitter < 0 {
|
|
return fmt.Errorf("scheduler job %q jitter must not be negative", job.Name)
|
|
}
|
|
if job.Run == nil {
|
|
return fmt.Errorf("scheduler job %q run function is required", job.Name)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.running {
|
|
return fmt.Errorf("scheduler job %q cannot be added after start", job.Name)
|
|
}
|
|
if _, exists := s.jobs[job.Name]; exists {
|
|
return fmt.Errorf("scheduler job %q already exists", job.Name)
|
|
}
|
|
s.jobs[job.Name] = &jobState{job: job}
|
|
return nil
|
|
}
|
|
|
|
func (s *Scheduler) Start(ctx context.Context) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
s.mu.Lock()
|
|
if s.running {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
s.mu.Unlock()
|
|
return err
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
jobs := make([]*jobState, 0, len(s.jobs))
|
|
for _, state := range s.jobs {
|
|
jobs = append(jobs, state)
|
|
}
|
|
// worker 必须在 running 对 Stop 可见前全部登记,禁止 Wait 与 Add 交错。
|
|
s.wg.Add(len(jobs))
|
|
s.cancel = cancel
|
|
s.running = true
|
|
s.mu.Unlock()
|
|
|
|
for _, state := range jobs {
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.loop(runCtx, state)
|
|
}()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Scheduler) Stop(ctx context.Context) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
s.mu.Lock()
|
|
cancel := s.cancel
|
|
if cancel == nil && !s.running {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
s.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
s.wg.Wait()
|
|
s.mu.Lock()
|
|
s.cancel = nil
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) Snapshot() []JobSnapshot {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]JobSnapshot, 0, len(s.jobs))
|
|
for _, state := range s.jobs {
|
|
out = append(out, state.snapshot())
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func (s *Scheduler) loop(ctx context.Context, state *jobState) {
|
|
interval := state.job.Interval
|
|
delay := s.nextDelay(state)
|
|
s.setNextRun(state, s.now().Add(delay))
|
|
timer := time.NewTimer(delay)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-timer.C:
|
|
s.runJob(ctx, state)
|
|
delay = interval + s.jitterDelay(state)
|
|
next := s.now().Add(delay)
|
|
s.setNextRun(state, next)
|
|
timer.Reset(delay)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) nextDelay(state *jobState) time.Duration {
|
|
delay := state.job.Interval + s.jitterDelay(state)
|
|
if delay <= 0 {
|
|
return time.Nanosecond
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func (s *Scheduler) jitterDelay(state *jobState) time.Duration {
|
|
if state == nil || state.job.Jitter <= 0 || s.jitter == nil {
|
|
return 0
|
|
}
|
|
delay := s.jitter(state.job.Jitter)
|
|
if delay < 0 {
|
|
return 0
|
|
}
|
|
if delay > state.job.Jitter {
|
|
return state.job.Jitter
|
|
}
|
|
return delay
|
|
}
|
|
|
|
func (s *Scheduler) runJob(ctx context.Context, state *jobState) {
|
|
now := s.now().UTC()
|
|
s.mu.Lock()
|
|
if state.running {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
state.running = true
|
|
state.lastStarted = now
|
|
s.mu.Unlock()
|
|
|
|
err := state.job.Run(ctx)
|
|
ended := s.now().UTC()
|
|
|
|
s.mu.Lock()
|
|
state.running = false
|
|
state.runs++
|
|
state.lastEnded = ended
|
|
if err != nil {
|
|
state.errors++
|
|
state.lastError = err.Error()
|
|
if s.logger != nil {
|
|
s.logger.Error("scheduler job failed", "job", state.job.Name, "error", err)
|
|
}
|
|
} else {
|
|
state.lastError = ""
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Scheduler) setNextRun(state *jobState, next time.Time) {
|
|
s.mu.Lock()
|
|
state.nextRun = next.UTC()
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *jobState) snapshot() JobSnapshot {
|
|
snap := JobSnapshot{
|
|
Name: s.job.Name,
|
|
IntervalMS: s.job.Interval.Milliseconds(),
|
|
JitterMS: s.job.Jitter.Milliseconds(),
|
|
Running: s.running,
|
|
Runs: s.runs,
|
|
Errors: s.errors,
|
|
LastError: s.lastError,
|
|
}
|
|
if !s.lastStarted.IsZero() {
|
|
t := s.lastStarted
|
|
snap.LastStarted = &t
|
|
}
|
|
if !s.lastEnded.IsZero() {
|
|
t := s.lastEnded
|
|
snap.LastEnded = &t
|
|
}
|
|
if !s.nextRun.IsZero() {
|
|
t := s.nextRun
|
|
snap.NextRun = &t
|
|
}
|
|
return snap
|
|
}
|
|
|
|
func randomJitter(max time.Duration) time.Duration {
|
|
if max <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(rand.Int63n(int64(max) + 1)) // #nosec G404 -- 非安全用途:调度抖动,不用于凭证或加密。
|
|
}
|