feat: add cache, outbound HTTP, request protection, tasks and module scaffolding

This commit is contained in:
2026-09-23 13:38:25 +08:00
parent 467c890363
commit fe66221a9c
34 changed files with 3100 additions and 1 deletions

68
cache/redis/redis.go vendored Normal file
View File

@@ -0,0 +1,68 @@
// Package redis adapts go-redis's pooled client to cache.Store.
package redis
import (
"context"
"errors"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/cache"
goredis "github.com/redis/go-redis/v9"
)
// Store borrows the client; its owner must close the client after users stop.
type Store struct {
client goredis.Cmdable
prefix string
}
func New(client goredis.Cmdable, prefix string) (*Store, error) {
if client == nil || len(prefix) > 1024 || strings.ContainsAny(prefix, "\r\n") {
return nil, errors.New("cache/redis: invalid client or prefix")
}
return &Store{client: client, prefix: prefix}, nil
}
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
if err := cache.Check(ctx, key, 0); err != nil {
return nil, err
}
value, err := s.client.Get(ctx, s.prefix+key).Bytes()
if errors.Is(err, goredis.Nil) {
return nil, cache.ErrMiss
}
return value, err
}
func (s *Store) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if err := cache.Check(ctx, key, ttl); err != nil {
return err
}
// Redis expiration precision is milliseconds; round up, never truncate a
// positive duration to an unbounded lifetime.
if ttl > 0 && ttl < time.Millisecond {
ttl = time.Millisecond
}
return s.client.Set(ctx, s.prefix+key, value, ttl).Err()
}
func (s *Store) Delete(ctx context.Context, keys ...string) error {
if ctx == nil {
return errors.New("cache/redis: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
names := make([]string, len(keys))
for i, key := range keys {
if err := cache.Check(ctx, key, 0); err != nil {
return err
}
names[i] = s.prefix + key
}
if len(names) == 0 {
return nil
}
return s.client.Del(ctx, names...).Err()
}

125
cache/redis/redis_test.go vendored Normal file
View File

@@ -0,0 +1,125 @@
package redis
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/cache"
goredis "github.com/redis/go-redis/v9"
)
type fakeClient struct {
goredis.Cmdable
key string
ttl time.Duration
failure error
}
func (f *fakeClient) Get(ctx context.Context, key string) *goredis.StringCmd {
f.key = key
return goredis.NewStringResult("", f.failure)
}
func (f *fakeClient) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) *goredis.StatusCmd {
f.key, f.ttl = key, ttl
return goredis.NewStatusResult("OK", f.failure)
}
func (f *fakeClient) Del(ctx context.Context, keys ...string) *goredis.IntCmd {
f.key = keys[0]
return goredis.NewIntResult(1, f.failure)
}
func TestAdapterPreservesErrorsAndExpiration(t *testing.T) {
f := &fakeClient{failure: goredis.Nil}
store, _ := New(f, "app:")
if _, err := store.Get(context.Background(), "key"); !errors.Is(err, cache.ErrMiss) || f.key != "app:key" {
t.Fatal(err, f.key)
}
f.failure = errors.New("unavailable")
if _, err := store.Get(context.Background(), "key"); !errors.Is(err, f.failure) {
t.Fatal(err)
}
f.failure = nil
if err := store.Set(context.Background(), "key", []byte("value"), time.Nanosecond); err != nil || f.ttl != time.Millisecond {
t.Fatal(err, f.ttl)
}
if err := store.Set(context.Background(), "key", nil, -time.Second); err == nil {
t.Fatal("negative TTL accepted")
}
if err := store.Delete(context.Background(), "key"); err != nil || f.key != "app:key" {
t.Fatal(err)
}
}
func TestRedisIntegration(t *testing.T) {
address := os.Getenv("GO_OMNI_REDIS_TEST_ADDR")
if address == "" {
t.Skip("set GO_OMNI_REDIS_TEST_ADDR to an isolated test Redis")
}
client := goredis.NewClient(&goredis.Options{Addr: address, PoolSize: 2, MaxRetries: -1, ContextTimeoutEnabled: true, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second})
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
t.Fatal(err)
}
prefix := fmt.Sprintf("goOmniTest:%d:", time.Now().UnixNano())
store, _ := New(client, prefix)
defer store.Delete(context.Background(), "key", "forever", "list", "canceled")
if err := store.Set(ctx, "key", []byte{0, 1, 255}, 80*time.Millisecond); err != nil {
t.Fatal(err)
}
v, err := store.Get(ctx, "key")
if err != nil || len(v) != 3 || v[2] != 255 {
t.Fatal(v, err)
}
deadline := time.Now().Add(time.Second)
for {
_, err := store.Get(ctx, "key")
if errors.Is(err, cache.ErrMiss) {
break
}
if err != nil || time.Now().After(deadline) {
t.Fatal("TTL failed", err)
}
time.Sleep(5 * time.Millisecond)
}
if err := store.Set(ctx, "forever", []byte("ok"), 0); err != nil {
t.Fatal(err)
}
if ttl := client.PTTL(ctx, prefix+"forever").Val(); ttl != -1 {
t.Fatalf("unexpected persistent TTL %v", ttl)
}
if err := store.Delete(ctx, "forever"); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, "forever"); !errors.Is(err, cache.ErrMiss) {
t.Fatal(err)
}
if err := client.RPush(ctx, prefix+"list", "value").Err(); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, "list"); err == nil || errors.Is(err, cache.ErrMiss) {
t.Fatal("Redis error treated as miss", err)
}
canceled, stop := context.WithCancel(ctx)
stop()
if err := store.Set(canceled, "canceled", []byte("value"), 0); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if _, err := store.Get(ctx, "canceled"); !errors.Is(err, cache.ErrMiss) {
t.Fatal("canceled write reached Redis", err)
}
if err := store.Delete(ctx, "list"); err != nil {
t.Fatal(err)
}
if err := client.Close(); err != nil {
t.Fatal(err)
}
if _, err := store.Get(ctx, "key"); err == nil || errors.Is(err, cache.ErrMiss) {
t.Fatal("closed client treated as miss", err)
}
}