package teamsalarysettlement import ( "context" "log/slog" "strings" "sync" "time" ) const autoRunnerInterval = time.Hour // AutoRunner 是 admin-server 内的轻量自动结算循环;真正的幂等不靠 runner,而靠 wallet 结算进度表。 type AutoRunner struct { service *Service appCode string worker string cancel context.CancelFunc wg sync.WaitGroup } // NewAutoRunner 只保存运行参数,不立刻启动 goroutine,便于 main 按 jobs.enabled 控制生命周期。 func NewAutoRunner(service *Service, appCode string, worker string) *AutoRunner { return &AutoRunner{ service: service, appCode: normalizeAppCode(appCode), worker: strings.TrimSpace(worker), } } // Start 启动后台循环;重复调用不创建第二个 worker,避免同进程内重复扫描。 func (r *AutoRunner) Start() { if r == nil || r.service == nil || r.cancel != nil { return } ctx, cancel := context.WithCancel(context.Background()) r.cancel = cancel r.wg.Add(1) go r.loop(ctx) } // Close 停止后台循环并等待退出,保证 admin-server 优雅关闭时不会截断正在执行的事务。 func (r *AutoRunner) Close() { if r == nil || r.cancel == nil { return } r.cancel() r.wg.Wait() r.cancel = nil } // loop 启动后先立即检查一次,再按小时检查;是否真正发薪由 RunAutomaticDue 按日期判断。 func (r *AutoRunner) loop(ctx context.Context) { defer r.wg.Done() r.runOnce(ctx, time.Now()) ticker := time.NewTicker(autoRunnerInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return case now := <-ticker.C: r.runOnce(ctx, now) } } } // runOnce 给单次自动结算设置超时,避免数据库异常时 goroutine 永久卡住。 func (r *AutoRunner) runOnce(parent context.Context, now time.Time) { ctx, cancel := context.WithTimeout(parent, 10*time.Minute) defer cancel() // 多节点同时触发时不单独抢锁;结算进度行和 command_id 唯一索引会保证同一周期同一用户只发一次。 result, err := r.service.RunAutomaticDue(ctx, r.appCode, now) if err != nil { slog.Error("team_salary_auto_settlement_failed", "worker", r.worker, "error", err) return } if !result.Due { return } slog.Info("team_salary_auto_settlement_completed", "worker", r.worker, "cycle_key", result.CycleKey, "bd_success", result.Results[policyTypeBD].SuccessCount, "bd_skipped", result.Results[policyTypeBD].SkippedCount, "admin_success", result.Results[policyTypeAdmin].SuccessCount, "admin_skipped", result.Results[policyTypeAdmin].SkippedCount, ) } // normalizeAppCode 兜底默认应用编码,保证自动任务和后台手动入口都使用同一 app 维度。 func normalizeAppCode(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "lalu" } return value }