Files
aiaa-notification-server/internal/subscriber/tradesignal/store.go
T
ryan b396638438 feat(交易信号): 持仓状态持久化至 Redis 以保障重启与多实例下均价计算准确
Motivation:
持仓与已处理信号的状态此前仅存于进程内存,服务重启或多实例部署后会丢失,导致加仓均价、仓位大小等计算失真,重复信号也无法跨实例幂等。通过将状态持久化到 Redis,保证持仓跟踪跨重启、跨实例连续一致,提升通知内容的准确性与可靠性。

Changes:

* 新增持仓存储抽象,支持内存与 Redis 两种实现,持仓状态与已处理信号快照按 TTL 持久化
* 持仓变更通过 Redis 事务管道原子提交,保证状态更新与幂等记录一致写入
* 存储写入失败时消息进入重试而非直接确认,避免状态丢失导致通知失真
* 缓存层新增原始值读取与批量事务写入能力,并在订阅器初始化时注入 Redis 依赖
* 补充跨实例持久化、幂等去重与存储失败场景的测试覆盖
2026-08-23 14:43:53 +08:00

146 lines
3.4 KiB
Go

package tradesignal
import (
"context"
"encoding/json"
"sync"
"time"
"aiaa-notification-service/internal/cache"
)
const (
positionKeyPrefix = "notify:position:"
appliedKeyPrefix = "notify:position:applied:"
positionTTL = 30 * 24 * time.Hour
appliedTTL = 7 * 24 * time.Hour
storeTimeout = 2 * time.Second
)
type persistedState struct {
Avg float64 `json:"avg"`
Size float64 `json:"size"`
Mode int `json:"mode"`
}
type positionStore interface {
load(key string) (*state, error)
commit(key string, st *state, del bool, signalID string, snap Snapshot) error
loadApplied(signalID string) (Snapshot, bool, error)
}
type memoryStore struct {
mu sync.Mutex
positions map[string]*state
applied map[string]Snapshot
}
func newMemoryStore() *memoryStore {
return &memoryStore{
positions: make(map[string]*state),
applied: make(map[string]Snapshot),
}
}
func (m *memoryStore) load(key string) (*state, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.positions[key], nil
}
func (m *memoryStore) loadApplied(signalID string) (Snapshot, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
snap, ok := m.applied[signalID]
return snap, ok, nil
}
func (m *memoryStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
m.mu.Lock()
defer m.mu.Unlock()
if del {
delete(m.positions, key)
} else if st != nil {
m.positions[key] = st
}
if signalID != "" {
m.applied[signalID] = snap
}
return nil
}
type kvClient interface {
GetRaw(ctx context.Context, key string) ([]byte, error)
TxWrite(ctx context.Context, writes []cache.KVWrite) error
}
type redisStore struct {
c kvClient
}
func newRedisStore(c *cache.Cache) positionStore {
if c == nil {
return newMemoryStore()
}
return &redisStore{c: c}
}
func (s *redisStore) ctx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), storeTimeout)
}
func (s *redisStore) load(key string) (*state, error) {
ctx, cancel := s.ctx()
defer cancel()
b, err := s.c.GetRaw(ctx, positionKeyPrefix+key)
if err != nil || len(b) == 0 {
return nil, err
}
var p persistedState
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
return &state{avg: p.Avg, size: p.Size, mode: mode(p.Mode)}, nil
}
func (s *redisStore) loadApplied(signalID string) (Snapshot, bool, error) {
if signalID == "" {
return Snapshot{}, false, nil
}
ctx, cancel := s.ctx()
defer cancel()
b, err := s.c.GetRaw(ctx, appliedKeyPrefix+signalID)
if err != nil || len(b) == 0 {
return Snapshot{}, false, err
}
var snap Snapshot
if err := json.Unmarshal(b, &snap); err != nil {
return Snapshot{}, false, err
}
return snap, true, nil
}
func (s *redisStore) commit(key string, st *state, del bool, signalID string, snap Snapshot) error {
ctx, cancel := s.ctx()
defer cancel()
writes := make([]cache.KVWrite, 0, 2)
posKey := positionKeyPrefix + key
if del {
writes = append(writes, cache.KVWrite{Key: posKey, Delete: true})
} else if st != nil {
b, err := json.Marshal(persistedState{Avg: st.avg, Size: st.size, Mode: int(st.mode)})
if err != nil {
return err
}
writes = append(writes, cache.KVWrite{Key: posKey, Val: b, TTL: positionTTL})
}
if signalID != "" {
b, err := json.Marshal(snap)
if err != nil {
return err
}
writes = append(writes, cache.KVWrite{Key: appliedKeyPrefix + signalID, Val: b, TTL: appliedTTL})
}
return s.c.TxWrite(ctx, writes)
}