DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,576 @@
|
||||
package adminworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWorkflowNotFound = errors.New("admin workflow not found")
|
||||
ErrWorkflowInvalid = errors.New("admin workflow is invalid")
|
||||
ErrWorkflowPhase = errors.New("admin workflow phase is invalid")
|
||||
ErrPreviewerRequired = errors.New("admin workflow previewer is required")
|
||||
ErrExecutorRequired = errors.New("admin workflow executor is required")
|
||||
ErrApprovalStoreRequired = errors.New("admin workflow approval store is required")
|
||||
ErrDryRunWarnings = errors.New("admin workflow dry-run has blocking warnings")
|
||||
ErrStoreRequired = errors.New("admin workflow store is required")
|
||||
)
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseDryRun Phase = "dry_run"
|
||||
PhasePendingApproval Phase = "pending_approval"
|
||||
PhaseApproved Phase = "approved"
|
||||
PhaseRejected Phase = "rejected"
|
||||
PhaseExecuted Phase = "executed"
|
||||
PhaseFailed Phase = "failed"
|
||||
PhaseRollbackNoted Phase = "rollback_noted"
|
||||
)
|
||||
|
||||
type Diff struct {
|
||||
Path string `json:"path"`
|
||||
Before any `json:"before,omitempty"`
|
||||
After any `json:"after,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
}
|
||||
|
||||
type DryRun struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Diffs []Diff `json:"diffs,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type RollbackNote struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Steps []string `json:"steps,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Workflow string `json:"workflow,omitempty"`
|
||||
Command admincmd.Command `json:"command"`
|
||||
Phase Phase `json:"phase"`
|
||||
DryRun DryRun `json:"dry_run,omitempty"`
|
||||
Approval *admincmd.ApprovalRequest `json:"approval,omitempty"`
|
||||
Receipt *admincmd.Receipt `json:"receipt,omitempty"`
|
||||
RollbackNote *RollbackNote `json:"rollback_note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type StartResult struct {
|
||||
Record Record `json:"record"`
|
||||
Duplicate bool `json:"duplicate,omitempty"`
|
||||
}
|
||||
|
||||
type Previewer interface {
|
||||
DryRunAdminWorkflow(context.Context, admincmd.Command) (DryRun, error)
|
||||
}
|
||||
|
||||
type PreviewerFunc func(context.Context, admincmd.Command) (DryRun, error)
|
||||
|
||||
func (fn PreviewerFunc) DryRunAdminWorkflow(ctx context.Context, command admincmd.Command) (DryRun, error) {
|
||||
if fn == nil {
|
||||
return DryRun{}, ErrPreviewerRequired
|
||||
}
|
||||
return fn(ctx, command)
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
ExecuteAdminWorkflow(context.Context, Record) (admincmd.Receipt, RollbackNote, error)
|
||||
}
|
||||
|
||||
type ExecutorFunc func(context.Context, Record) (admincmd.Receipt, RollbackNote, error)
|
||||
|
||||
func (fn ExecutorFunc) ExecuteAdminWorkflow(ctx context.Context, record Record) (admincmd.Receipt, RollbackNote, error) {
|
||||
if fn == nil {
|
||||
return admincmd.Receipt{}, RollbackNote{}, ErrExecutorRequired
|
||||
}
|
||||
return fn(ctx, record)
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
SaveWorkflow(context.Context, Record) (Record, error)
|
||||
GetWorkflow(context.Context, string) (Record, bool, error)
|
||||
ListWorkflows(context.Context) ([]Record, error)
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
CommandPolicy admincmd.Policy
|
||||
RequireApproval bool
|
||||
BlockWarnings bool
|
||||
}
|
||||
|
||||
func DangerousPolicy() Policy {
|
||||
return Policy{
|
||||
CommandPolicy: admincmd.DangerousApprovalPolicy().CommandPolicy,
|
||||
RequireApproval: true,
|
||||
BlockWarnings: false,
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOptions struct {
|
||||
Store Store
|
||||
Approval *admincmd.ApprovalStore
|
||||
Policy Policy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
approval *admincmd.ApprovalStore
|
||||
policy Policy
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(options ServiceOptions) *Service {
|
||||
policy := options.Policy
|
||||
if policy.CommandPolicy == (admincmd.Policy{}) {
|
||||
policy = DangerousPolicy()
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{
|
||||
store: options.Store,
|
||||
approval: options.Approval,
|
||||
policy: policy,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) StartDryRun(ctx context.Context, command admincmd.Command, previewer Previewer) (StartResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return StartResult{}, ErrStoreRequired
|
||||
}
|
||||
if previewer == nil {
|
||||
return StartResult{}, ErrPreviewerRequired
|
||||
}
|
||||
command = admincmd.Normalize(command)
|
||||
if err := admincmd.Validate(command, s.policy.CommandPolicy); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
id, err := WorkflowID(command)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if current, ok, err := s.store.GetWorkflow(ctx, id); err != nil || ok {
|
||||
return StartResult{Record: current, Duplicate: ok}, err
|
||||
}
|
||||
dryRun, err := previewer.DryRunAdminWorkflow(ctx, command)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
dryRun = normalizeDryRun(dryRun)
|
||||
if !hasDryRunPreview(dryRun) {
|
||||
return StartResult{}, fmt.Errorf("%w: dry-run must include summary or diffs", ErrWorkflowInvalid)
|
||||
}
|
||||
if s.policy.BlockWarnings && len(dryRun.Warnings) > 0 {
|
||||
return StartResult{}, fmt.Errorf("%w: %s", ErrDryRunWarnings, strings.Join(dryRun.Warnings, "; "))
|
||||
}
|
||||
now := s.now().UTC()
|
||||
record := Record{
|
||||
ID: id,
|
||||
Command: cloneCommand(command),
|
||||
Phase: PhaseDryRun,
|
||||
DryRun: dryRun,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
record, err = s.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
return StartResult{Record: record}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SubmitApproval(ctx context.Context, id string) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
if s.approval == nil {
|
||||
return Record{}, ErrApprovalStoreRequired
|
||||
}
|
||||
record, err := s.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if record.Phase != PhaseDryRun {
|
||||
if record.Approval != nil {
|
||||
return cloneRecord(record), nil
|
||||
}
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseDryRun, record.Phase)
|
||||
}
|
||||
request, err := s.approval.Submit(ctx, record.Command)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record.Approval = &request
|
||||
record.Phase = PhasePendingApproval
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
return s.store.SaveWorkflow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) Approve(ctx context.Context, id string, decision admincmd.ApprovalDecision) (Record, error) {
|
||||
return s.decide(ctx, id, decision, true)
|
||||
}
|
||||
|
||||
func (s *Service) Reject(ctx context.Context, id string, decision admincmd.ApprovalDecision) (Record, error) {
|
||||
return s.decide(ctx, id, decision, false)
|
||||
}
|
||||
|
||||
func (s *Service) Execute(ctx context.Context, id string, executor Executor) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
if executor == nil {
|
||||
return Record{}, ErrExecutorRequired
|
||||
}
|
||||
record, err := s.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if record.Phase == PhaseExecuted || record.Phase == PhaseRollbackNoted {
|
||||
return record, nil
|
||||
}
|
||||
if s.policy.RequireApproval {
|
||||
if record.Phase != PhaseApproved && record.Phase != PhaseFailed {
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseApproved, record.Phase)
|
||||
}
|
||||
if s.approval == nil || record.Approval == nil {
|
||||
return Record{}, ErrApprovalStoreRequired
|
||||
}
|
||||
approvalReceipt, err := s.approval.ExecuteApproved(ctx, record.Approval.ID)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
request, ok := s.approval.Get(record.Approval.ID)
|
||||
if ok {
|
||||
record.Approval = &request
|
||||
record.Command = cloneCommand(request.Command)
|
||||
}
|
||||
if record.Receipt == nil {
|
||||
record.Receipt = &approvalReceipt
|
||||
}
|
||||
} else if record.Phase != PhaseDryRun && record.Phase != PhaseFailed {
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseDryRun, record.Phase)
|
||||
}
|
||||
|
||||
receipt, rollback, err := executor.ExecuteAdminWorkflow(ctx, cloneRecord(record))
|
||||
if err != nil {
|
||||
record.Phase = PhaseFailed
|
||||
record.Error = strings.TrimSpace(err.Error())
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
_, _ = s.store.SaveWorkflow(ctx, record)
|
||||
return Record{}, err
|
||||
}
|
||||
if receipt.ID == "" {
|
||||
receipt = fallbackReceipt(record.Command, s.now)
|
||||
}
|
||||
record.Receipt = &receipt
|
||||
rollback = normRollbackNote(rollback)
|
||||
if hasRollbackNote(rollback) {
|
||||
record.RollbackNote = &rollback
|
||||
record.Phase = PhaseRollbackNoted
|
||||
} else {
|
||||
record.Phase = PhaseExecuted
|
||||
}
|
||||
record.Error = ""
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
return s.store.SaveWorkflow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id string) (Record, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, false, ErrStoreRequired
|
||||
}
|
||||
return s.store.GetWorkflow(ctx, strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
return s.store.ListWorkflows(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) decide(ctx context.Context, id string, decision admincmd.ApprovalDecision, approve bool) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
if s.approval == nil {
|
||||
return Record{}, ErrApprovalStoreRequired
|
||||
}
|
||||
record, err := s.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if record.Phase != PhasePendingApproval || record.Approval == nil {
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhasePendingApproval, record.Phase)
|
||||
}
|
||||
var request admincmd.ApprovalRequest
|
||||
if approve {
|
||||
request, err = s.approval.Approve(ctx, record.Approval.ID, decision)
|
||||
record.Phase = PhaseApproved
|
||||
} else {
|
||||
request, err = s.approval.Reject(ctx, record.Approval.ID, decision)
|
||||
record.Phase = PhaseRejected
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record.Approval = &request
|
||||
record.Command = cloneCommand(request.Command)
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
return s.store.SaveWorkflow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) mustGet(ctx context.Context, id string) (Record, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return Record{}, ErrWorkflowNotFound
|
||||
}
|
||||
record, ok, err := s.store.GetWorkflow(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Record{}, ErrWorkflowNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func WorkflowID(command admincmd.Command) (string, error) {
|
||||
command = admincmd.Normalize(command)
|
||||
paramsHash, err := admincmd.ParamsHash(command.Params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if command.Operation == "" || command.IdempotencyKey == "" {
|
||||
return "", ErrWorkflowInvalid
|
||||
}
|
||||
seed := strings.Join([]string{
|
||||
command.Operation,
|
||||
command.Scope,
|
||||
command.Environment,
|
||||
command.ShardID,
|
||||
command.Target,
|
||||
command.Actor,
|
||||
command.IdempotencyKey,
|
||||
paramsHash,
|
||||
}, "|")
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
return "workflow-" + hex.EncodeToString(sum[:12]), nil
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]Record
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{records: make(map[string]Record)}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveWorkflow(ctx context.Context, record Record) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
record.ID = strings.TrimSpace(record.ID)
|
||||
if record.ID == "" || record.Phase == "" {
|
||||
return Record{}, ErrWorkflowInvalid
|
||||
}
|
||||
record = cloneRecord(record)
|
||||
s.mu.Lock()
|
||||
if s.records == nil {
|
||||
s.records = make(map[string]Record)
|
||||
}
|
||||
s.records[record.ID] = record
|
||||
s.mu.Unlock()
|
||||
return cloneRecord(record), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetWorkflow(ctx context.Context, id string) (Record, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return Record{}, false, ErrStoreRequired
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
s.mu.Lock()
|
||||
record, ok := s.records[id]
|
||||
s.mu.Unlock()
|
||||
return cloneRecord(record), ok, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListWorkflows(ctx context.Context) ([]Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Record, 0, len(s.records))
|
||||
for _, record := range s.records {
|
||||
out = append(out, cloneRecord(record))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fallbackReceipt(command admincmd.Command, now func() time.Time) admincmd.Receipt {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
receipt, err := admincmd.NewReceipt(command, "executed", now().UTC())
|
||||
if err != nil {
|
||||
return admincmd.Receipt{Status: "executed", CreatedAt: now().UTC()}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func normalizeDryRun(dryRun DryRun) DryRun {
|
||||
dryRun.Summary = strings.TrimSpace(dryRun.Summary)
|
||||
dryRun.Diffs = cloneDiffs(dryRun.Diffs)
|
||||
dryRun.Warnings = normalizeStrings(dryRun.Warnings)
|
||||
dryRun.Meta = cloneStringMap(dryRun.Meta)
|
||||
return dryRun
|
||||
}
|
||||
|
||||
func normRollbackNote(note RollbackNote) RollbackNote {
|
||||
note.Summary = strings.TrimSpace(note.Summary)
|
||||
note.Steps = normalizeStrings(note.Steps)
|
||||
note.EvidenceRef = strings.TrimSpace(note.EvidenceRef)
|
||||
note.Meta = cloneStringMap(note.Meta)
|
||||
return note
|
||||
}
|
||||
|
||||
func hasRollbackNote(note RollbackNote) bool {
|
||||
return note.Summary != "" || len(note.Steps) > 0 || note.EvidenceRef != "" || len(note.Meta) > 0
|
||||
}
|
||||
|
||||
func hasDryRunPreview(dryRun DryRun) bool {
|
||||
return dryRun.Summary != "" || len(dryRun.Diffs) > 0
|
||||
}
|
||||
|
||||
func cloneRecord(record Record) Record {
|
||||
record.Command = cloneCommand(record.Command)
|
||||
record.DryRun = normalizeDryRun(record.DryRun)
|
||||
if record.Approval != nil {
|
||||
approval := *record.Approval
|
||||
approval.Command = cloneCommand(approval.Command)
|
||||
if approval.Receipt != nil {
|
||||
receipt := *approval.Receipt
|
||||
approval.Receipt = &receipt
|
||||
}
|
||||
record.Approval = &approval
|
||||
}
|
||||
if record.Receipt != nil {
|
||||
receipt := *record.Receipt
|
||||
record.Receipt = &receipt
|
||||
}
|
||||
if record.RollbackNote != nil {
|
||||
note := normRollbackNote(*record.RollbackNote)
|
||||
record.RollbackNote = ¬e
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func cloneCommand(command admincmd.Command) admincmd.Command {
|
||||
command = admincmd.Normalize(command)
|
||||
command.Params = cloneParams(command.Params)
|
||||
return command
|
||||
}
|
||||
|
||||
func cloneParams(params map[string]any) map[string]any {
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDiffs(diffs []Diff) []Diff {
|
||||
if len(diffs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Diff, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
diff.Path = strings.TrimSpace(diff.Path)
|
||||
diff.Risk = strings.TrimSpace(diff.Risk)
|
||||
out = append(out, diff)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
Reference in new issue
Block a user