Files
90CNServer/server/internal/runtime/redeem/redeem.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

528 lines
15 KiB
Go

package redeem
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"longheng.io/server/internal/platform/admincmd"
"longheng.io/server/internal/platform/eventlog"
)
var (
ErrCodeRequired = errors.New("redeem code is required")
ErrAccountRequired = errors.New("redeem account is required")
ErrStoreRequired = errors.New("redeem store is required")
ErrCodeNotFound = errors.New("redeem code not found")
ErrCodeInactive = errors.New("redeem code is inactive")
ErrCodeExhausted = errors.New("redeem code is exhausted")
ErrAccountLimitExceeded = errors.New("redeem account limit exceeded")
ErrClaimConflict = errors.New("redeem claim idempotency conflict")
ErrInvalidRedeemCode = errors.New("redeem code is invalid")
ErrInvalidRedeemOperation = errors.New("redeem operation is invalid")
)
type Code struct {
Code string `json:"code"`
Campaign string `json:"campaign,omitempty"`
RewardRef string `json:"reward_ref"`
MaxUses int `json:"max_uses,omitempty"`
PerAccountLimit int `json:"per_account_limit,omitempty"`
StartsAt time.Time `json:"starts_at,omitempty"`
EndsAt time.Time `json:"ends_at,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type ClaimRequest struct {
Code string `json:"code"`
AccountID string `json:"account_id"`
ShardID string `json:"shard_id,omitempty"`
Source string `json:"source,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
AdminCommand *admincmd.Command `json:"admin_command,omitempty"`
}
type ClaimResult struct {
Accepted bool `json:"accepted"`
Duplicate bool `json:"duplicate,omitempty"`
ClaimID string `json:"claim_id"`
Code string `json:"code"`
AccountID string `json:"account_id"`
ShardID string `json:"shard_id,omitempty"`
Source string `json:"source,omitempty"`
Campaign string `json:"campaign,omitempty"`
RewardRef string `json:"reward_ref"`
IdempotencyKey string `json:"idempotency_key"`
Uses int `json:"uses"`
Remaining int `json:"remaining"`
AdminReceiptID string `json:"admin_receipt_id,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
ClaimedAt time.Time `json:"claimed_at"`
}
type Store interface {
PutCode(context.Context, Code) (Code, error)
Claim(context.Context, ClaimRequest, time.Time) (ClaimResult, error)
Snapshot(context.Context) (Snapshot, error)
}
type Snapshot struct {
Codes int `json:"codes"`
Claims int `json:"claims"`
Uses map[string]int `json:"uses,omitempty"`
}
type Service struct {
Store Store
EventLog *eventlog.Log
EventLogStream string
EventLogType string
Now func() time.Time
}
func (s Service) PutCode(ctx context.Context, code Code) (Code, error) {
if err := ctxErr(ctx); err != nil {
return Code{}, err
}
if s.Store == nil {
return Code{}, ErrStoreRequired
}
return s.Store.PutCode(ctx, code)
}
func (s Service) Claim(ctx context.Context, request ClaimRequest) (ClaimResult, error) {
if err := ctxErr(ctx); err != nil {
return ClaimResult{}, err
}
if s.Store == nil {
return ClaimResult{}, ErrStoreRequired
}
request = normClaimReq(request)
if request.Code == "" {
return ClaimResult{}, ErrCodeRequired
}
if request.AccountID == "" {
return ClaimResult{}, ErrAccountRequired
}
if request.IdempotencyKey == "" {
request.IdempotencyKey = IdempotencyKey(request.Code, request.AccountID)
}
if request.AdminCommand != nil {
// 运营补发礼包码也走危险命令校验,确保有 reason、确认信息和幂等键。
command := *request.AdminCommand
command.Operation = firstNonEmpty(command.Operation, "redeem.repair")
command.Scope = firstNonEmpty(command.Scope, "redeem")
command.Target = firstNonEmpty(command.Target, request.AccountID)
command.Params = mergeCommandParams(command.Params, request)
if err := admincmd.Validate(command, admincmd.DangerousPolicy()); err != nil {
return ClaimResult{}, err
}
receipt, err := admincmd.NewReceipt(command, "accepted", s.now())
if err != nil {
return ClaimResult{}, err
}
request.AdminCommand = &command
request.Meta = mergeStringMap(request.Meta, map[string]string{"admin_receipt_id": receipt.ID})
}
if result, ok, err := s.lookupClaimEvent(ctx, request); err != nil || ok {
return result, err
}
result, err := s.Store.Claim(ctx, request, s.now())
if err != nil {
return result, err
}
if err := s.appendClaimEvent(ctx, result); err != nil {
return result, errors.Join(err, runClaimRollback(ctx, func(ctx context.Context) error {
return s.rollbackClaim(ctx, result)
}))
}
return result, nil
}
type claimRollbackStore interface {
RollbackClaim(context.Context, ClaimResult) error
}
func (s Service) rollbackClaim(ctx context.Context, result ClaimResult) error {
if result.Duplicate || s.Store == nil {
return nil
}
store, ok := s.Store.(claimRollbackStore)
if !ok {
return nil
}
return store.RollbackClaim(ctx, result)
}
func (s Service) Snapshot(ctx context.Context) (Snapshot, error) {
if err := ctxErr(ctx); err != nil {
return Snapshot{}, err
}
if s.Store == nil {
return Snapshot{}, ErrStoreRequired
}
return s.Store.Snapshot(ctx)
}
func (s Service) now() time.Time {
if s.Now != nil {
return s.Now().UTC()
}
return time.Now().UTC()
}
type MemoryStore struct {
mu sync.Mutex
codes map[string]Code
claimsByKey map[string]ClaimResult
claimsByAccount map[string]int
uses map[string]int
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
codes: make(map[string]Code),
claimsByKey: make(map[string]ClaimResult),
claimsByAccount: make(map[string]int),
uses: make(map[string]int),
}
}
func (s *MemoryStore) PutCode(ctx context.Context, code Code) (Code, error) {
if err := ctxErr(ctx); err != nil {
return Code{}, err
}
if s == nil {
return Code{}, ErrStoreRequired
}
code = normalizeCode(code)
if code.Code == "" {
return Code{}, ErrCodeRequired
}
if code.RewardRef == "" {
return Code{}, fmt.Errorf("%w: reward_ref is required", ErrInvalidRedeemCode)
}
if code.MaxUses < 0 || code.PerAccountLimit < 0 {
return Code{}, fmt.Errorf("%w: limits cannot be negative", ErrInvalidRedeemCode)
}
if code.PerAccountLimit == 0 {
code.PerAccountLimit = 1
}
if !code.StartsAt.IsZero() && !code.EndsAt.IsZero() && code.EndsAt.Before(code.StartsAt) {
return Code{}, fmt.Errorf("%w: ends_at is before starts_at", ErrInvalidRedeemCode)
}
s.mu.Lock()
s.codes[code.Code] = cloneCode(code)
s.mu.Unlock()
return cloneCode(code), nil
}
func (s *MemoryStore) Claim(ctx context.Context, request ClaimRequest, now time.Time) (ClaimResult, error) {
if err := ctxErr(ctx); err != nil {
return ClaimResult{}, err
}
if s == nil {
return ClaimResult{}, ErrStoreRequired
}
request = normClaimReq(request)
if request.Code == "" {
return ClaimResult{}, ErrCodeRequired
}
if request.AccountID == "" {
return ClaimResult{}, ErrAccountRequired
}
if request.IdempotencyKey == "" {
request.IdempotencyKey = IdempotencyKey(request.Code, request.AccountID)
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
s.mu.Lock()
defer s.mu.Unlock()
lookupKey := claimLookupKey(request)
if existing, ok := s.claimsByKey[lookupKey]; ok {
if claimReplayConflict(existing, request) {
return ClaimResult{}, ErrClaimConflict
}
// 礼包码领取按 code+account+key 重放,防止客户端超时重试重复扣次数。
existing.Duplicate = true
return cloneClaimResult(existing), nil
}
code, ok := s.codes[request.Code]
if !ok {
return ClaimResult{}, ErrCodeNotFound
}
if !codeActive(code, now) {
return ClaimResult{}, ErrCodeInactive
}
if code.MaxUses > 0 && s.uses[code.Code] >= code.MaxUses {
return ClaimResult{}, ErrCodeExhausted
}
accountKey := code.Code + "\x00" + request.AccountID
if s.claimsByAccount[accountKey] >= code.PerAccountLimit {
return ClaimResult{}, ErrAccountLimitExceeded
}
// 次数扣减和领取记录在同一把锁内完成;生产存储应使用同等原子写能力。
s.uses[code.Code]++
s.claimsByAccount[accountKey]++
remaining := -1
if code.MaxUses > 0 {
remaining = code.MaxUses - s.uses[code.Code]
}
result := ClaimResult{
Accepted: true,
ClaimID: ClaimID(request.Code, request.AccountID, request.IdempotencyKey),
Code: code.Code,
AccountID: request.AccountID,
ShardID: request.ShardID,
Source: request.Source,
Campaign: code.Campaign,
RewardRef: code.RewardRef,
IdempotencyKey: request.IdempotencyKey,
Uses: s.uses[code.Code],
Remaining: remaining,
AdminReceiptID: request.Meta["admin_receipt_id"],
Meta: mergeStringMap(code.Meta, request.Meta),
ClaimedAt: now,
}
s.claimsByKey[lookupKey] = cloneClaimResult(result)
return cloneClaimResult(result), nil
}
func (s *MemoryStore) RollbackClaim(ctx context.Context, result ClaimResult) error {
if err := ctxErr(ctx); err != nil {
return err
}
if s == nil {
return ErrStoreRequired
}
if result.Code == "" || result.AccountID == "" || result.IdempotencyKey == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
request := ClaimRequest{Code: result.Code, AccountID: result.AccountID, IdempotencyKey: result.IdempotencyKey}
lookupKey := claimLookupKey(request)
existing, ok := s.claimsByKey[lookupKey]
if !ok || existing.ClaimID != result.ClaimID {
return nil
}
delete(s.claimsByKey, lookupKey)
accountKey := result.Code + "\x00" + result.AccountID
if s.claimsByAccount[accountKey] <= 1 {
delete(s.claimsByAccount, accountKey)
} else {
s.claimsByAccount[accountKey]--
}
if s.uses[result.Code] <= 1 {
delete(s.uses, result.Code)
} else {
s.uses[result.Code]--
}
return nil
}
func (s *MemoryStore) Snapshot(ctx context.Context) (Snapshot, error) {
if err := ctxErr(ctx); err != nil {
return Snapshot{}, err
}
if s == nil {
return Snapshot{}, ErrStoreRequired
}
s.mu.Lock()
defer s.mu.Unlock()
return Snapshot{
Codes: len(s.codes),
Claims: len(s.claimsByKey),
Uses: cloneIntMap(s.uses),
}, nil
}
func normalizeCode(code Code) Code {
code.Code = strings.TrimSpace(code.Code)
code.Campaign = strings.TrimSpace(code.Campaign)
code.RewardRef = strings.TrimSpace(code.RewardRef)
code.StartsAt = normalizeTime(code.StartsAt)
code.EndsAt = normalizeTime(code.EndsAt)
code.Meta = normalizeStringMap(code.Meta)
return code
}
func normClaimReq(request ClaimRequest) ClaimRequest {
request.Code = strings.TrimSpace(request.Code)
request.AccountID = strings.TrimSpace(request.AccountID)
request.ShardID = strings.TrimSpace(request.ShardID)
request.Source = strings.TrimSpace(request.Source)
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
request.Meta = normalizeStringMap(request.Meta)
return request
}
func codeActive(code Code, now time.Time) bool {
if code.Disabled {
return false
}
if !code.StartsAt.IsZero() && now.Before(code.StartsAt) {
return false
}
if !code.EndsAt.IsZero() && now.After(code.EndsAt) {
return false
}
return true
}
func IdempotencyKey(code, accountID string) string {
code = strings.TrimSpace(code)
accountID = strings.TrimSpace(accountID)
if code == "" || accountID == "" {
return ""
}
return strings.Join([]string{"redeem", "v1", encodeEventKeyPart(code), encodeEventKeyPart(accountID)}, ":")
}
func ClaimID(code, accountID, key string) string {
seed := strings.Join([]string{strings.TrimSpace(code), strings.TrimSpace(accountID), strings.TrimSpace(key)}, "\x00")
sum := sha256.Sum256([]byte(seed))
return hex.EncodeToString(sum[:16])
}
func claimLookupKey(request ClaimRequest) string {
return strings.TrimSpace(request.Code) + "\x00" + strings.TrimSpace(request.AccountID) + "\x00" + strings.TrimSpace(request.IdempotencyKey)
}
func claimReplayConflict(existing ClaimResult, request ClaimRequest) bool {
if request.Code != "" && existing.Code != request.Code {
return true
}
if request.AccountID != "" && existing.AccountID != request.AccountID {
return true
}
if request.ShardID != "" && existing.ShardID != request.ShardID {
return true
}
if request.Source != "" && existing.Source != request.Source {
return true
}
if len(request.Meta) > 0 {
for key, value := range request.Meta {
if key == "admin_receipt_id" {
continue
}
if existing.Meta[key] != value {
return true
}
}
}
return false
}
func mergeCommandParams(params map[string]any, request ClaimRequest) map[string]any {
out := make(map[string]any, len(params)+4)
for key, value := range params {
out[key] = value
}
out["code"] = request.Code
out["account_id"] = request.AccountID
out["shard_id"] = request.ShardID
out["idempotency_key"] = request.IdempotencyKey
return out
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func normalizeTime(value time.Time) time.Time {
if value.IsZero() {
return time.Time{}
}
return value.UTC()
}
func normalizeStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
key = strings.TrimSpace(key)
if key == "" {
continue
}
out[key] = strings.TrimSpace(value)
}
if len(out) == 0 {
return nil
}
return out
}
func mergeStringMap(base map[string]string, overlays ...map[string]string) map[string]string {
out := cloneStringMap(base)
for _, overlay := range overlays {
for key, value := range overlay {
if out == nil {
out = make(map[string]string)
}
out[key] = value
}
}
return out
}
func cloneCode(code Code) Code {
code.Meta = cloneStringMap(code.Meta)
return code
}
func cloneClaimResult(result ClaimResult) ClaimResult {
result.Meta = cloneStringMap(result.Meta)
return result
}
func cloneStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
out := make(map[string]string, len(values))
for key, value := range values {
out[key] = value
}
return out
}
func cloneIntMap(values map[string]int) map[string]int {
if len(values) == 0 {
return nil
}
out := make(map[string]int, len(values))
for key, value := range values {
out[key] = value
}
return out
}
func ctxErr(ctx context.Context) error {
if ctx == nil {
return nil
}
return ctx.Err()
}