feat: 分支闯关判分与终局结算(含单测)

choose 校验节点/选项归属与解锁链,终局在 common.WithLock 内按
技术设计 4.3/4.4 结算:首次到达才结算、连续失败限扣、余额不为负、
完美集齐全终局 +20 并解锁计策卡与下一计;路径流水尽力而为。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 12:47:01 +08:00
co-authored by Claude Opus 4.7
parent d2887bf8a3
commit 414c7de007
8 changed files with 691 additions and 5 deletions
+63
View File
@@ -0,0 +1,63 @@
package common
import (
"context"
"crypto/rand"
"encoding/hex"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
)
// ErrLockHeld 锁被占用:重试 retries 次仍未获取时返回。
var ErrLockHeld = gerror.New("操作过于频繁,请稍后再试")
// WithLock 业务互斥锁(唯一入口):
// - expire 必须 > 0,进程崩溃后锁自动过期兜底不死锁;fn 耗时必须小于 expire,fn 内禁止长耗时 IO
// - 拿不到锁重试 retries 次、每次间隔 retryIntervalretries=0 立即失败;ctx 取消/超时同样终止等待)
// - 中间件故障不重试,直接返回错误
// - 锁实现按配置自动选择:配置了 redis 节点 → redis 锁(跨实例互斥);未配置 → gcache 内存锁(单实例互斥)。
// redis 后端需引入 github.com/gogf/gf/contrib/redis/v2 适配器并接入 SET NX EX + token 对比删除;
// 当前未配置 redis 时使用内存锁。
// - 无论 fn 成功、失败还是 panic,锁在函数退出时自动释放
func WithLock[T any](ctx context.Context, key string, expire time.Duration, retries int, retryInterval time.Duration, fn func() (T, error)) (T, error) {
var zero T
if expire <= 0 {
return zero, gerror.New("锁过期时间必须大于 0")
}
if lockBackendIsRedis(ctx) {
return zero, gerror.New("配置了 redis 锁后端,但当前未接入 contrib/redis 适配器")
}
token := randomToken()
for attempt := 0; ; attempt++ {
ok, err := gcache.SetIfNotExist(ctx, key, token, expire)
if err != nil {
return zero, err
}
if ok {
defer func() { _, _ = gcache.Remove(ctx, key) }()
return fn()
}
if attempt >= retries {
return zero, ErrLockHeld
}
select {
case <-ctx.Done():
return zero, ctx.Err()
case <-time.After(retryInterval):
}
}
}
func lockBackendIsRedis(ctx context.Context) bool {
v, err := g.Cfg().Get(ctx, "redis", nil)
return err == nil && v != nil && !v.IsEmpty()
}
func randomToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}