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,224 @@
|
||||
package storageobject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrPermissionDenied = errors.New("storage object permission denied")
|
||||
|
||||
type Subject struct {
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Admin bool `json:"admin,omitempty"`
|
||||
System bool `json:"system,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
func (s *Service) Close() error {
|
||||
if s == nil || s.store == nil {
|
||||
return nil
|
||||
}
|
||||
if closer, ok := s.store.(interface{ Close() error }); ok {
|
||||
return closer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Write(ctx context.Context, subject Subject, request WriteRequest) (Object, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return Object{}, ErrObjectNotFound
|
||||
}
|
||||
key, err := normalizeKey(request.Object.Key)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
current, ok, err := s.store.Read(ctx, key)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
guard := request.Object
|
||||
if ok {
|
||||
guard = current
|
||||
}
|
||||
if err := AuthorizeWrite(subject, guard); err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
return s.store.Write(ctx, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchWrite(ctx context.Context, subject Subject, request BatchWriteRequest) BatchWriteResult {
|
||||
result := BatchWriteResult{Results: make([]BatchObjectResult, 0, len(request.Writes))}
|
||||
for _, write := range request.Writes {
|
||||
object, err := s.Write(ctx, subject, write)
|
||||
item := BatchObjectResult{Object: object, Found: err == nil}
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
}
|
||||
result.Results = append(result.Results, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) Read(ctx context.Context, subject Subject, key Key) (Object, bool, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return Object{}, false, nil
|
||||
}
|
||||
object, ok, err := s.store.Read(ctx, key)
|
||||
if err != nil || !ok {
|
||||
return object, ok, err
|
||||
}
|
||||
if err := AuthorizeRead(subject, object); err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
return object, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) BatchRead(ctx context.Context, subject Subject, request BatchReadRequest) BatchReadResult {
|
||||
result := BatchReadResult{Results: make([]BatchObjectResult, 0, len(request.Keys))}
|
||||
for _, key := range request.Keys {
|
||||
object, ok, err := s.Read(ctx, subject, key)
|
||||
item := BatchObjectResult{Object: object, Found: ok}
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
}
|
||||
result.Results = append(result.Results, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, subject Subject, key Key, ifMatchVersion string) error {
|
||||
if s == nil || s.store == nil {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
object, ok, err := s.store.Read(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
if err := AuthorizeWrite(subject, object); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Delete(ctx, key, ifMatchVersion)
|
||||
}
|
||||
|
||||
func (s *Service) BatchDelete(ctx context.Context, subject Subject, request BatchDeleteRequest) BatchDeleteResults {
|
||||
result := BatchDeleteResults{Results: make([]BatchDeleteResult, 0, len(request.Deletes))}
|
||||
for _, deletion := range request.Deletes {
|
||||
err := s.Delete(ctx, subject, deletion.Key, deletion.IfMatchVersion)
|
||||
item := BatchDeleteResult{Key: deletion.Key, Deleted: err == nil}
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
}
|
||||
result.Results = append(result.Results, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, subject Subject, request ListRequest) (ListResult, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return ListResult{}, nil
|
||||
}
|
||||
if normalizeSubject(subject).Admin {
|
||||
result, err := s.store.List(ctx, request)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
limit := normalizeListLimit(request.Limit)
|
||||
result := ListResult{Objects: make([]Object, 0, limit)}
|
||||
scan := request
|
||||
scan.Limit = limit
|
||||
cursor := strings.TrimSpace(request.Cursor)
|
||||
for {
|
||||
scan.Cursor = cursor
|
||||
page, err := s.store.List(ctx, scan)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
for _, object := range page.Objects {
|
||||
if !CanRead(subject, object) {
|
||||
continue
|
||||
}
|
||||
result.Objects = append(result.Objects, object)
|
||||
if len(result.Objects) >= limit {
|
||||
result.NextCursor = objectID(object.Key)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(page.NextCursor) == "" {
|
||||
return result, nil
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
}
|
||||
|
||||
func AuthorizeRead(subject Subject, object Object) error {
|
||||
if CanRead(subject, object) {
|
||||
return nil
|
||||
}
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
|
||||
func AuthorizeWrite(subject Subject, object Object) error {
|
||||
if CanWrite(subject, object) {
|
||||
return nil
|
||||
}
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
|
||||
func CanRead(subject Subject, object Object) bool {
|
||||
subject = normalizeSubject(subject)
|
||||
if subject.Admin {
|
||||
return true
|
||||
}
|
||||
switch normalizePermission(object.PermissionRead) {
|
||||
case PermissionPublic:
|
||||
return true
|
||||
case PermissionOwner:
|
||||
if subject.System {
|
||||
return object.OwnerKind() == OwnerKindSystem
|
||||
}
|
||||
return ownsObject(subject, object)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func CanWrite(subject Subject, object Object) bool {
|
||||
subject = normalizeSubject(subject)
|
||||
if subject.Admin {
|
||||
return true
|
||||
}
|
||||
switch normalizePermission(object.PermissionWrite) {
|
||||
case PermissionPublic:
|
||||
return subject.UserID != "" || subject.System
|
||||
case PermissionOwner:
|
||||
if subject.System {
|
||||
return object.OwnerKind() == OwnerKindSystem
|
||||
}
|
||||
return ownsObject(subject, object)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ownsObject(subject Subject, object Object) bool {
|
||||
owner := strings.TrimSpace(object.Key.UserID)
|
||||
return subject.UserID != "" && owner != "" && subject.UserID == owner
|
||||
}
|
||||
|
||||
func normalizeSubject(subject Subject) Subject {
|
||||
subject.UserID = strings.TrimSpace(subject.UserID)
|
||||
return subject
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package storageobject
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
)
|
||||
|
||||
type AdminOptions struct {
|
||||
Prefix string
|
||||
Wrap func(http.HandlerFunc) http.HandlerFunc
|
||||
WrapDangerous func(operation string, next http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
|
||||
const maxAdminJSONBytes = 1 << 20
|
||||
|
||||
type AdminObjectView struct {
|
||||
Object Object `json:"object"`
|
||||
OwnerKind string `json:"owner_kind"`
|
||||
}
|
||||
|
||||
func RegisterAdminRoutes(mux *http.ServeMux, service *Service, options AdminOptions) error {
|
||||
if mux == nil {
|
||||
return errors.New("storage object admin mux is required")
|
||||
}
|
||||
if service == nil {
|
||||
return errors.New("storage object admin service is required")
|
||||
}
|
||||
prefix := strings.TrimRight(strings.TrimSpace(options.Prefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = "/storage"
|
||||
}
|
||||
wrap := options.Wrap
|
||||
if wrap == nil {
|
||||
wrap = func(next http.HandlerFunc) http.HandlerFunc { return next }
|
||||
}
|
||||
wrapDangerous := options.WrapDangerous
|
||||
if wrapDangerous == nil {
|
||||
wrapDangerous = func(_ string, next http.HandlerFunc) http.HandlerFunc { return wrap(next) }
|
||||
}
|
||||
mux.HandleFunc(prefix+"/objects", wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
result, err := service.List(r.Context(), Subject{Admin: true}, ListRequest{
|
||||
Collection: r.URL.Query().Get("collection"),
|
||||
UserID: r.URL.Query().Get("user_id"),
|
||||
Index: adminIndexFromQuery(r),
|
||||
Limit: limit,
|
||||
Cursor: r.URL.Query().Get("cursor"),
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(r.URL.Query().Get("owner"))
|
||||
views := make([]AdminObjectView, 0, len(result.Objects))
|
||||
for _, object := range result.Objects {
|
||||
if owner != "" && object.OwnerKind() != owner {
|
||||
continue
|
||||
}
|
||||
views = append(views, AdminObjectView{Object: object, OwnerKind: object.OwnerKind()})
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"objects": views,
|
||||
"next_cursor": result.NextCursor,
|
||||
})
|
||||
}))
|
||||
readObject := wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
object, ok, err := service.Read(r.Context(), Subject{Admin: true}, adminKeyFromQuery(r))
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrObjectNotFound.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, AdminObjectView{Object: object, OwnerKind: object.OwnerKind()})
|
||||
})
|
||||
writeOrDeleteObject := wrapDangerous("storageobject.object.write_delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
var request WriteRequest
|
||||
if !decodeAdminJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
object, err := service.Write(r.Context(), Subject{Admin: true}, request)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, AdminObjectView{Object: object, OwnerKind: object.OwnerKind()})
|
||||
case http.MethodDelete:
|
||||
if err := service.Delete(r.Context(), Subject{Admin: true}, adminKeyFromQuery(r), r.URL.Query().Get("version")); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrObjectNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
})
|
||||
readBatch := wrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
var request BatchReadRequest
|
||||
if !decodeAdminJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, service.BatchRead(r.Context(), Subject{Admin: true}, request))
|
||||
})
|
||||
writeBatch := wrapDangerous("storageobject.objects.write", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
var request BatchWriteRequest
|
||||
if !decodeAdminJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, service.BatchWrite(r.Context(), Subject{Admin: true}, request))
|
||||
})
|
||||
deleteBatch := wrapDangerous("storageobject.objects.delete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
var request BatchDeleteRequest
|
||||
if !decodeAdminJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, service.BatchDelete(r.Context(), Subject{Admin: true}, request))
|
||||
})
|
||||
mux.HandleFunc(prefix+"/objects/read", readBatch)
|
||||
mux.HandleFunc(prefix+"/objects/write", writeBatch)
|
||||
mux.HandleFunc(prefix+"/objects/delete", deleteBatch)
|
||||
mux.HandleFunc(prefix+"/object", func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
readObject(w, r)
|
||||
case http.MethodPost, http.MethodDelete:
|
||||
writeOrDeleteObject(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST, DELETE")
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeAdminJSON(w http.ResponseWriter, r *http.Request, out any) bool {
|
||||
if err := httpx.DecodeStrictJSON(w, r, maxAdminJSONBytes, out); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func adminKeyFromQuery(r *http.Request) Key {
|
||||
query := r.URL.Query()
|
||||
return Key{
|
||||
Collection: query.Get("collection"),
|
||||
Key: query.Get("key"),
|
||||
UserID: query.Get("user_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func adminIndexFromQuery(r *http.Request) map[string]string {
|
||||
index := make(map[string]string)
|
||||
for key, values := range r.URL.Query() {
|
||||
switch {
|
||||
case strings.HasPrefix(key, "index."):
|
||||
name := strings.TrimSpace(strings.TrimPrefix(key, "index."))
|
||||
if name != "" && len(values) > 0 {
|
||||
index[name] = values[0]
|
||||
}
|
||||
case strings.HasPrefix(key, "idx_"):
|
||||
name := strings.TrimSpace(strings.TrimPrefix(key, "idx_"))
|
||||
if name != "" && len(values) > 0 {
|
||||
index[name] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeIndex(index)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package storageobject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/filex"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidArchiveKey = errors.New("storage archive key is invalid")
|
||||
ErrArchiveCorrupt = errors.New("storage archive checksum mismatch")
|
||||
)
|
||||
|
||||
type ArchiveRecord struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ArchiveStore interface {
|
||||
Put(context.Context, string, []byte, map[string]string) (ArchiveRecord, error)
|
||||
Get(context.Context, string) ([]byte, ArchiveRecord, bool, error)
|
||||
Delete(context.Context, string) error
|
||||
}
|
||||
|
||||
type LocalArchiveStore struct {
|
||||
root string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewLocalArchiveStore(root string, now func() time.Time) (*LocalArchiveStore, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return nil, ErrInvalidArchiveKey
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &LocalArchiveStore{root: root, now: now}, nil
|
||||
}
|
||||
|
||||
func (s *LocalArchiveStore) Put(ctx context.Context, key string, data []byte, meta map[string]string) (ArchiveRecord, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
path, manifestPath, normalized, err := s.paths(key)
|
||||
if err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
data = append([]byte(nil), data...)
|
||||
record := ArchiveRecord{
|
||||
Key: normalized,
|
||||
Size: int64(len(data)),
|
||||
SHA256: sha256Hex(data),
|
||||
Meta: cloneArchiveMeta(meta),
|
||||
UpdatedAt: s.now().UTC(),
|
||||
}
|
||||
manifest, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
previousData, hadData, err := readArchiveFile(path)
|
||||
if err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
previousManifest, hadManifest, err := readArchiveFile(manifestPath)
|
||||
if err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
if err := writeFileAtomic(path, data, 0o600); err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
if err := writeFileAtomic(manifestPath, manifest, 0o600); err != nil {
|
||||
restoreErr := restoreArchiveFiles(path, previousData, hadData, manifestPath, previousManifest, hadManifest)
|
||||
return ArchiveRecord{}, errors.Join(err, restoreErr)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *LocalArchiveStore) Get(ctx context.Context, key string) ([]byte, ArchiveRecord, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
path, manifestPath, _, err := s.paths(key)
|
||||
if err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ArchiveRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
var record ArchiveRecord
|
||||
manifest, err := os.ReadFile(manifestPath) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
record = ArchiveRecord{Key: filepath.ToSlash(strings.TrimSpace(key)), Size: int64(len(data)), SHA256: sha256Hex(data)}
|
||||
return data, record, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &record); err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
if record.SHA256 != "" && record.SHA256 != sha256Hex(data) {
|
||||
return nil, ArchiveRecord{}, false, ErrArchiveCorrupt
|
||||
}
|
||||
return data, record, true, nil
|
||||
}
|
||||
|
||||
func (s *LocalArchiveStore) Delete(ctx context.Context, key string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
path, manifestPath, _, err := s.paths(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(manifestPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LocalArchiveStore) paths(key string) (string, string, string, error) {
|
||||
if s == nil || strings.TrimSpace(s.root) == "" {
|
||||
return "", "", "", ErrInvalidArchiveKey
|
||||
}
|
||||
normalized, err := normalizeArchiveKey(key)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
path := filepath.Join(append([]string{s.root}, strings.Split(normalized, "/")...)...)
|
||||
manifestPath := path + ".manifest.json"
|
||||
root, err := filepath.Abs(s.root)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if !strings.HasPrefix(absPath, root+string(os.PathSeparator)) && absPath != root {
|
||||
return "", "", "", ErrInvalidArchiveKey
|
||||
}
|
||||
return path, manifestPath, normalized, nil
|
||||
}
|
||||
|
||||
var archiveKeyPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
func normalizeArchiveKey(key string) (string, error) {
|
||||
key = filepath.ToSlash(strings.TrimSpace(key))
|
||||
key = strings.Trim(key, "/")
|
||||
if key == "" || strings.Contains(key, "..") {
|
||||
return "", ErrInvalidArchiveKey
|
||||
}
|
||||
parts := strings.Split(key, "/")
|
||||
for _, part := range parts {
|
||||
if part == "" || !archiveKeyPattern.MatchString(part) {
|
||||
return "", ErrInvalidArchiveKey
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "/"), nil
|
||||
}
|
||||
|
||||
var writeFileAtomic = filex.AtomicWriteFile
|
||||
|
||||
func readArchiveFile(path string) ([]byte, bool, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自已校验的 archive key。
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return append([]byte(nil), data...), true, nil
|
||||
}
|
||||
|
||||
func restoreArchiveFiles(path string, data []byte, hadData bool, manifestPath string, manifest []byte, hadManifest bool) error {
|
||||
return errors.Join(
|
||||
restoreArchiveFile(path, data, hadData),
|
||||
restoreArchiveFile(manifestPath, manifest, hadManifest),
|
||||
)
|
||||
}
|
||||
|
||||
func restoreArchiveFile(path string, data []byte, existed bool) error {
|
||||
if existed {
|
||||
return writeFileAtomic(path, data, 0o600)
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func cloneArchiveMeta(meta map[string]string) map[string]string {
|
||||
if len(meta) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(meta))
|
||||
for key, value := range meta {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key != "" && value != "" {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package storageobject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const archiveRecordMetaKey = "longheng-archive-record"
|
||||
|
||||
type CloudObjectClient interface {
|
||||
PutObject(context.Context, string, []byte, map[string]string) error
|
||||
GetObject(context.Context, string) ([]byte, map[string]string, bool, error)
|
||||
DeleteObject(context.Context, string) error
|
||||
}
|
||||
|
||||
type CloudArchiveStore struct {
|
||||
client CloudObjectClient
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewCloudArchiveStore(client CloudObjectClient, now func() time.Time) (*CloudArchiveStore, error) {
|
||||
if client == nil {
|
||||
return nil, ErrInvalidArchiveKey
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &CloudArchiveStore{client: client, now: now}, nil
|
||||
}
|
||||
|
||||
func (s *CloudArchiveStore) Put(ctx context.Context, key string, data []byte, meta map[string]string) (ArchiveRecord, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
if s == nil || s.client == nil {
|
||||
return ArchiveRecord{}, ErrInvalidArchiveKey
|
||||
}
|
||||
normalized, err := normalizeArchiveKey(key)
|
||||
if err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
data = append([]byte(nil), data...)
|
||||
record := ArchiveRecord{
|
||||
Key: normalized,
|
||||
Size: int64(len(data)),
|
||||
SHA256: sha256Hex(data),
|
||||
Meta: cloneArchiveMeta(meta),
|
||||
UpdatedAt: s.now().UTC(),
|
||||
}
|
||||
objectMeta, err := encodeArchiveMeta(record)
|
||||
if err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
if err := s.client.PutObject(ctx, normalized, data, objectMeta); err != nil {
|
||||
return ArchiveRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *CloudArchiveStore) Get(ctx context.Context, key string) ([]byte, ArchiveRecord, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
if s == nil || s.client == nil {
|
||||
return nil, ArchiveRecord{}, false, ErrInvalidArchiveKey
|
||||
}
|
||||
normalized, err := normalizeArchiveKey(key)
|
||||
if err != nil {
|
||||
return nil, ArchiveRecord{}, false, err
|
||||
}
|
||||
data, meta, ok, err := s.client.GetObject(ctx, normalized)
|
||||
if err != nil || !ok {
|
||||
return nil, ArchiveRecord{}, ok, err
|
||||
}
|
||||
record := decodeArchiveMeta(normalized, data, meta)
|
||||
if record.SHA256 != "" && record.SHA256 != sha256Hex(data) {
|
||||
return nil, ArchiveRecord{}, false, ErrArchiveCorrupt
|
||||
}
|
||||
return append([]byte(nil), data...), record, true, nil
|
||||
}
|
||||
|
||||
func (s *CloudArchiveStore) Delete(ctx context.Context, key string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.client == nil {
|
||||
return ErrInvalidArchiveKey
|
||||
}
|
||||
normalized, err := normalizeArchiveKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.client.DeleteObject(ctx, normalized)
|
||||
}
|
||||
|
||||
func encodeArchiveMeta(record ArchiveRecord) (map[string]string, error) {
|
||||
meta := cloneArchiveMeta(record.Meta)
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = make(map[string]string, 1)
|
||||
}
|
||||
meta[archiveRecordMetaKey] = string(data)
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func decodeArchiveMeta(key string, data []byte, meta map[string]string) ArchiveRecord {
|
||||
if raw := meta[archiveRecordMetaKey]; raw != "" {
|
||||
var record ArchiveRecord
|
||||
if err := json.Unmarshal([]byte(raw), &record); err == nil && record.Key != "" {
|
||||
record.Meta = cloneArchiveMeta(record.Meta)
|
||||
return record
|
||||
}
|
||||
}
|
||||
return ArchiveRecord{
|
||||
Key: key,
|
||||
Size: int64(len(data)),
|
||||
SHA256: sha256Hex(data),
|
||||
Meta: cloneArchiveMeta(meta),
|
||||
UpdatedAt: parseArchiveTime(meta["updated_at"]),
|
||||
}
|
||||
}
|
||||
|
||||
func parseArchiveTime(value string) time.Time {
|
||||
if value == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return ts.UTC()
|
||||
}
|
||||
if unixNano, err := strconv.ParseInt(value, 10, 64); err == nil && unixNano > 0 {
|
||||
return time.Unix(0, unixNano).UTC()
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package storageobject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSQLDBRequired = errors.New("storage object sql db is required")
|
||||
ErrInvalidTable = errors.New("storage object sql table is invalid")
|
||||
ErrInvalidSQLRows = errors.New("storage object sql rows are invalid")
|
||||
)
|
||||
|
||||
const defaultSQLTable = "storage_objects"
|
||||
|
||||
var sqlTablePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
|
||||
type SQLStatements struct {
|
||||
Table string
|
||||
EnsureSchema string
|
||||
EnsureIndexColumn string
|
||||
Insert string
|
||||
Upsert string
|
||||
UpdateWithVersion string
|
||||
SelectOne string
|
||||
Delete string
|
||||
DeleteWithVersion string
|
||||
List string
|
||||
}
|
||||
|
||||
type SQLStore struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
stmts SQLStatements
|
||||
}
|
||||
|
||||
type SQLOptions struct {
|
||||
DB *sql.DB
|
||||
Table string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func NewSQLStore(options SQLOptions) (*SQLStore, error) {
|
||||
if options.DB == nil {
|
||||
return nil, ErrSQLDBRequired
|
||||
}
|
||||
stmts, err := NewSQLStatements(options.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &SQLStore{db: options.DB, now: now, stmts: stmts}, nil
|
||||
}
|
||||
|
||||
func NewSQLStatements(table string) (SQLStatements, error) {
|
||||
table, err := normalizeSQLTable(table)
|
||||
if err != nil {
|
||||
return SQLStatements{}, err
|
||||
}
|
||||
quoted := "`" + table + "`"
|
||||
return SQLStatements{
|
||||
Table: table,
|
||||
EnsureSchema: fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
collection VARCHAR(128) NOT NULL,
|
||||
object_key VARCHAR(256) NOT NULL,
|
||||
user_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
value_json JSON NOT NULL,
|
||||
index_json JSON NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
permission_read INT NOT NULL,
|
||||
permission_write INT NOT NULL,
|
||||
created_at TIMESTAMP(6) NOT NULL,
|
||||
updated_at TIMESTAMP(6) NOT NULL,
|
||||
PRIMARY KEY (collection, user_id, object_key),
|
||||
KEY idx_storage_object_list (collection, user_id, object_key)
|
||||
)`, quoted),
|
||||
EnsureIndexColumn: fmt.Sprintf(`ALTER TABLE %s ADD COLUMN index_json JSON NULL AFTER value_json`, quoted),
|
||||
Insert: fmt.Sprintf(`INSERT INTO %s (collection, object_key, user_id, value_json, index_json, version, permission_read, permission_write, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, quoted),
|
||||
Upsert: fmt.Sprintf(`INSERT INTO %s (collection, object_key, user_id, value_json, index_json, version, permission_read, permission_write, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value_json=VALUES(value_json), index_json=VALUES(index_json), version=VALUES(version), permission_read=VALUES(permission_read), permission_write=VALUES(permission_write), updated_at=VALUES(updated_at)`, quoted),
|
||||
UpdateWithVersion: fmt.Sprintf(`UPDATE %s SET value_json=?, index_json=?, version=?, permission_read=?, permission_write=?, updated_at=? WHERE collection=? AND user_id=? AND object_key=? AND version=?`, quoted),
|
||||
SelectOne: fmt.Sprintf(`SELECT collection, object_key, user_id, value_json, index_json, version, permission_read, permission_write, created_at, updated_at FROM %s WHERE collection=? AND user_id=? AND object_key=?`, quoted),
|
||||
Delete: fmt.Sprintf(`DELETE FROM %s WHERE collection=? AND user_id=? AND object_key=?`, quoted),
|
||||
DeleteWithVersion: fmt.Sprintf(`DELETE FROM %s WHERE collection=? AND user_id=? AND object_key=? AND version=?`, quoted),
|
||||
List: fmt.Sprintf(`SELECT collection, object_key, user_id, value_json, index_json, version, permission_read, permission_write, created_at, updated_at FROM %s WHERE collection=? AND (?='' OR user_id=?) AND CONCAT(collection, '/', user_id, '/', object_key)>? ORDER BY collection, user_id, object_key LIMIT ?`, quoted),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) EnsureSchema(ctx context.Context) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
stmts, err := s.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, stmts.EnsureSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, stmts.EnsureIndexColumn); err != nil && !isDupColumnErr(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *SQLStore) Write(ctx context.Context, request WriteRequest) (Object, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
stmts, err := s.ready()
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
now := s.nowUTC()
|
||||
object, err := normalizeObject(request.Object, now)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
if request.IfMatchVersion != "" {
|
||||
current, ok, err := s.Read(ctx, object.Key)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
if !ok || current.Version != strings.TrimSpace(request.IfMatchVersion) {
|
||||
return Object{}, ErrVersionConflict
|
||||
}
|
||||
object.CreatedAt = current.CreatedAt
|
||||
object.Version = versionFor(object)
|
||||
result, err := s.db.ExecContext(ctx, stmts.UpdateWithVersion,
|
||||
[]byte(object.Value), sqlIndexJSON(object), object.Version, object.PermissionRead, object.PermissionWrite, object.UpdatedAt,
|
||||
object.Key.Collection, object.Key.UserID, object.Key.Key, strings.TrimSpace(request.IfMatchVersion),
|
||||
)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return Object{}, ErrVersionConflict
|
||||
}
|
||||
return cloneObject(object), nil
|
||||
}
|
||||
if request.IfNoneMatch {
|
||||
object.Version = versionFor(object)
|
||||
_, err := s.db.ExecContext(ctx, stmts.Insert, sqlObjectArgs(object)...)
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
return Object{}, ErrVersionConflict
|
||||
}
|
||||
return Object{}, err
|
||||
}
|
||||
return cloneObject(object), nil
|
||||
}
|
||||
if current, ok, err := s.Read(ctx, object.Key); err != nil {
|
||||
return Object{}, err
|
||||
} else if ok {
|
||||
object.CreatedAt = current.CreatedAt
|
||||
}
|
||||
object.Version = versionFor(object)
|
||||
_, err = s.db.ExecContext(ctx, stmts.Upsert, sqlObjectArgs(object)...)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
return cloneObject(object), nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) Read(ctx context.Context, key Key) (Object, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
stmts, err := s.ready()
|
||||
if err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
key, err = normalizeKey(key)
|
||||
if err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, stmts.SelectOne, key.Collection, key.UserID, key.Key)
|
||||
object, err := scanObject(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Object{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
return object, true, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) Delete(ctx context.Context, key Key, ifMatchVersion string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
stmts, err := s.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err = normalizeKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ifMatchVersion = strings.TrimSpace(ifMatchVersion)
|
||||
stmt := stmts.Delete
|
||||
args := []any{key.Collection, key.UserID, key.Key}
|
||||
if ifMatchVersion != "" {
|
||||
stmt = stmts.DeleteWithVersion
|
||||
args = append(args, ifMatchVersion)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
if ifMatchVersion != "" {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) List(ctx context.Context, request ListRequest) (result ListResult, err error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
stmts, err := s.ready()
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
collection := strings.TrimSpace(request.Collection)
|
||||
if collection == "" {
|
||||
return ListResult{}, ErrInvalidKey
|
||||
}
|
||||
userID := strings.TrimSpace(request.UserID)
|
||||
limit := normalizeListLimit(request.Limit)
|
||||
cursor := strings.TrimSpace(request.Cursor)
|
||||
indexFilters := normalizeIndex(request.Index)
|
||||
query, indexArgs := listQuery(stmts, indexFilters)
|
||||
args := []any{collection, userID, userID, cursor}
|
||||
args = append(args, indexArgs...)
|
||||
args = append(args, limit+1)
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
if rows == nil {
|
||||
return ListResult{}, ErrInvalidSQLRows
|
||||
}
|
||||
defer closeSQLRowsErr(rows, &err)
|
||||
|
||||
var objects []Object
|
||||
for rows.Next() {
|
||||
object, err := scanObject(rows)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
objects = append(objects, object)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
result = ListResult{Objects: objects}
|
||||
if len(result.Objects) > limit {
|
||||
result.Objects = result.Objects[:limit]
|
||||
result.NextCursor = objectID(result.Objects[len(result.Objects)-1].Key)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) ready() (SQLStatements, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return SQLStatements{}, ErrSQLDBRequired
|
||||
}
|
||||
if sqlStatementsReady(s.stmts) {
|
||||
return s.stmts, nil
|
||||
}
|
||||
table := strings.TrimSpace(s.stmts.Table)
|
||||
if table == "" {
|
||||
table = defaultSQLTable
|
||||
}
|
||||
return NewSQLStatements(table)
|
||||
}
|
||||
|
||||
func (s *SQLStore) nowUTC() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return s.now().UTC()
|
||||
}
|
||||
|
||||
func sqlStatementsReady(stmts SQLStatements) bool {
|
||||
return strings.TrimSpace(stmts.Table) != "" &&
|
||||
stmts.EnsureSchema != "" &&
|
||||
stmts.EnsureIndexColumn != "" &&
|
||||
stmts.Insert != "" &&
|
||||
stmts.Upsert != "" &&
|
||||
stmts.UpdateWithVersion != "" &&
|
||||
stmts.SelectOne != "" &&
|
||||
stmts.Delete != "" &&
|
||||
stmts.DeleteWithVersion != "" &&
|
||||
stmts.List != ""
|
||||
}
|
||||
|
||||
func closeSQLRowsErr(rows interface{ Close() error }, err *error) {
|
||||
if rows == nil || err == nil {
|
||||
return
|
||||
}
|
||||
if closeErr := rows.Close(); closeErr != nil && *err == nil {
|
||||
*err = closeErr
|
||||
}
|
||||
}
|
||||
|
||||
func listQuery(stmts SQLStatements, indexFilters map[string]string) (string, []any) {
|
||||
indexFilters = normalizeIndex(indexFilters)
|
||||
if len(indexFilters) == 0 {
|
||||
return stmts.List, nil
|
||||
}
|
||||
keys := make([]string, 0, len(indexFilters))
|
||||
for key := range indexFilters {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
quoted := "`" + stmts.Table + "`"
|
||||
var builder strings.Builder
|
||||
builder.WriteString(`SELECT collection, object_key, user_id, value_json, index_json, version, permission_read, permission_write, created_at, updated_at FROM `)
|
||||
builder.WriteString(quoted)
|
||||
builder.WriteString(` WHERE collection=? AND (?='' OR user_id=?) AND CONCAT(collection, '/', user_id, '/', object_key)>?`)
|
||||
args := make([]any, 0, len(keys)*2)
|
||||
for _, key := range keys {
|
||||
builder.WriteString(` AND JSON_UNQUOTE(JSON_EXTRACT(COALESCE(index_json, JSON_OBJECT()), ?)) = ?`)
|
||||
args = append(args, jsonPathForIndexKey(key), indexFilters[key])
|
||||
}
|
||||
builder.WriteString(` ORDER BY collection, user_id, object_key LIMIT ?`)
|
||||
return builder.String(), args
|
||||
}
|
||||
|
||||
func jsonPathForIndexKey(key string) string {
|
||||
return "$." + strconv.Quote(strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
type objectScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanObject(scanner objectScanner) (Object, error) {
|
||||
var object Object
|
||||
var value []byte
|
||||
var indexJSON []byte
|
||||
if err := scanner.Scan(
|
||||
&object.Key.Collection,
|
||||
&object.Key.Key,
|
||||
&object.Key.UserID,
|
||||
&value,
|
||||
&indexJSON,
|
||||
&object.Version,
|
||||
&object.PermissionRead,
|
||||
&object.PermissionWrite,
|
||||
&object.CreatedAt,
|
||||
&object.UpdatedAt,
|
||||
); err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
object.Value = append([]byte(nil), value...)
|
||||
object.Index = decodeIndexJSON(indexJSON)
|
||||
object.CreatedAt = object.CreatedAt.UTC()
|
||||
object.UpdatedAt = object.UpdatedAt.UTC()
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func sqlObjectArgs(object Object) []any {
|
||||
return []any{
|
||||
object.Key.Collection,
|
||||
object.Key.Key,
|
||||
object.Key.UserID,
|
||||
[]byte(object.Value),
|
||||
sqlIndexJSON(object),
|
||||
object.Version,
|
||||
object.PermissionRead,
|
||||
object.PermissionWrite,
|
||||
object.CreatedAt,
|
||||
object.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sqlIndexJSON(object Object) []byte {
|
||||
data, _ := json.Marshal(normalizeIndex(object.Index))
|
||||
if string(data) == "null" {
|
||||
return []byte(`{}`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeIndexJSON(data []byte) map[string]string {
|
||||
if len(data) == 0 || string(data) == "null" {
|
||||
return nil
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return normalizeIndex(out)
|
||||
}
|
||||
|
||||
func normalizeSQLTable(table string) (string, error) {
|
||||
table = strings.TrimSpace(table)
|
||||
if table == "" {
|
||||
table = defaultSQLTable
|
||||
}
|
||||
if !sqlTablePattern.MatchString(table) {
|
||||
return "", ErrInvalidTable
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "duplicate") || strings.Contains(text, "1062")
|
||||
}
|
||||
|
||||
func isDupColumnErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
return strings.Contains(text, "duplicate column") || strings.Contains(text, "1060")
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package storageobject
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidKey = errors.New("storage object key is invalid")
|
||||
ErrInvalidObject = errors.New("storage object is invalid")
|
||||
ErrObjectNotFound = errors.New("storage object not found")
|
||||
ErrVersionConflict = errors.New("storage object version conflict")
|
||||
)
|
||||
|
||||
// Storage Object 只保存非权威轻量对象,权威玩家资产仍应走 Profile/MySQL。
|
||||
const (
|
||||
PermissionNoAccess = 0
|
||||
PermissionOwner = 1
|
||||
PermissionPublic = 2
|
||||
)
|
||||
|
||||
const (
|
||||
OwnerKindUser = "user"
|
||||
OwnerKindSystem = "system"
|
||||
)
|
||||
|
||||
type Key struct {
|
||||
Collection string `json:"collection"`
|
||||
Key string `json:"key"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
func (k Key) OwnerKind() string {
|
||||
if strings.TrimSpace(k.UserID) == "" {
|
||||
return OwnerKindSystem
|
||||
}
|
||||
return OwnerKindUser
|
||||
}
|
||||
|
||||
type Object struct {
|
||||
Key Key `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Index map[string]string `json:"index,omitempty"`
|
||||
Version string `json:"version"`
|
||||
PermissionRead int `json:"permission_read,omitempty"`
|
||||
PermissionWrite int `json:"permission_write,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (o Object) OwnerKind() string {
|
||||
return o.Key.OwnerKind()
|
||||
}
|
||||
|
||||
type WriteRequest struct {
|
||||
Object Object `json:"object"`
|
||||
IfMatchVersion string `json:"if_match_version,omitempty"`
|
||||
IfNoneMatch bool `json:"if_none_match,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteRequest struct {
|
||||
Key Key `json:"key"`
|
||||
IfMatchVersion string `json:"if_match_version,omitempty"`
|
||||
}
|
||||
|
||||
type BatchReadRequest struct {
|
||||
Keys []Key `json:"keys"`
|
||||
}
|
||||
|
||||
type BatchWriteRequest struct {
|
||||
Writes []WriteRequest `json:"writes"`
|
||||
}
|
||||
|
||||
type BatchDeleteRequest struct {
|
||||
Deletes []DeleteRequest `json:"deletes"`
|
||||
}
|
||||
|
||||
type BatchObjectResult struct {
|
||||
Object Object `json:"object,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type BatchDeleteResult struct {
|
||||
Key Key `json:"key"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type BatchReadResult struct {
|
||||
Results []BatchObjectResult `json:"results"`
|
||||
}
|
||||
|
||||
type BatchWriteResult struct {
|
||||
Results []BatchObjectResult `json:"results"`
|
||||
}
|
||||
|
||||
type BatchDeleteResults struct {
|
||||
Results []BatchDeleteResult `json:"results"`
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
Collection string `json:"collection"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Index map[string]string `json:"index,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Objects []Object `json:"objects"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Write(context.Context, WriteRequest) (Object, error)
|
||||
Read(context.Context, Key) (Object, bool, error)
|
||||
Delete(context.Context, Key, string) error
|
||||
List(context.Context, ListRequest) (ListResult, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
objects map[string]Object
|
||||
}
|
||||
|
||||
func NewMemoryStore(now func() time.Time) *MemoryStore {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &MemoryStore{now: now, objects: make(map[string]Object)}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Write(ctx context.Context, request WriteRequest) (Object, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Object{}, ErrObjectNotFound
|
||||
}
|
||||
object, err := normalizeObject(request.Object, s.now().UTC())
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
id := objectID(object.Key)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.objects == nil {
|
||||
s.objects = make(map[string]Object)
|
||||
}
|
||||
current, exists := s.objects[id]
|
||||
if request.IfNoneMatch && exists {
|
||||
return Object{}, ErrVersionConflict
|
||||
}
|
||||
if request.IfMatchVersion != "" {
|
||||
if !exists || current.Version != strings.TrimSpace(request.IfMatchVersion) {
|
||||
return Object{}, ErrVersionConflict
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
object.CreatedAt = current.CreatedAt
|
||||
}
|
||||
object.Version = versionFor(object)
|
||||
s.objects[id] = cloneObject(object)
|
||||
return cloneObject(object), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Read(ctx context.Context, key Key) (Object, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return Object{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return Object{}, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
object, ok := s.objects[objectID(key)]
|
||||
s.mu.Unlock()
|
||||
return cloneObject(object), ok, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) Delete(ctx context.Context, key Key, ifMatchVersion string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := normalizeKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id := objectID(key)
|
||||
current, ok := s.objects[id]
|
||||
if !ok {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
if ifMatchVersion != "" && current.Version != strings.TrimSpace(ifMatchVersion) {
|
||||
return ErrVersionConflict
|
||||
}
|
||||
delete(s.objects, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) List(ctx context.Context, request ListRequest) (ListResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
collection := strings.TrimSpace(request.Collection)
|
||||
if collection == "" {
|
||||
return ListResult{}, ErrInvalidKey
|
||||
}
|
||||
userID := strings.TrimSpace(request.UserID)
|
||||
indexFilters := normalizeIndex(request.Index)
|
||||
limit := normalizeListLimit(request.Limit)
|
||||
cursor := strings.TrimSpace(request.Cursor)
|
||||
if s == nil {
|
||||
return ListResult{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
objects := make([]Object, 0, len(s.objects))
|
||||
for _, object := range s.objects {
|
||||
if object.Key.Collection != collection {
|
||||
continue
|
||||
}
|
||||
if userID != "" && object.Key.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if !matchIndex(object, indexFilters) {
|
||||
continue
|
||||
}
|
||||
objects = append(objects, cloneObject(object))
|
||||
}
|
||||
s.mu.Unlock()
|
||||
sort.Slice(objects, func(i, j int) bool { return objectID(objects[i].Key) < objectID(objects[j].Key) })
|
||||
start := 0
|
||||
if cursor != "" {
|
||||
for idx, object := range objects {
|
||||
if objectID(object.Key) > cursor {
|
||||
start = idx
|
||||
break
|
||||
}
|
||||
start = idx + 1
|
||||
}
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(objects) {
|
||||
end = len(objects)
|
||||
}
|
||||
result := ListResult{Objects: objects[start:end]}
|
||||
if end < len(objects) {
|
||||
result.NextCursor = objectID(objects[end-1].Key)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeObject(object Object, now time.Time) (Object, error) {
|
||||
key, err := normalizeKey(object.Key)
|
||||
if err != nil {
|
||||
return Object{}, err
|
||||
}
|
||||
if len(object.Value) == 0 || !json.Valid(object.Value) {
|
||||
return Object{}, ErrInvalidObject
|
||||
}
|
||||
object.Key = key
|
||||
object.Value = append(json.RawMessage(nil), object.Value...)
|
||||
object.Index = normalizeIndex(object.Index)
|
||||
object.PermissionRead = normalizePermission(object.PermissionRead)
|
||||
object.PermissionWrite = normalizePermission(object.PermissionWrite)
|
||||
if object.CreatedAt.IsZero() {
|
||||
object.CreatedAt = now
|
||||
} else {
|
||||
object.CreatedAt = object.CreatedAt.UTC()
|
||||
}
|
||||
object.UpdatedAt = now
|
||||
return object, nil
|
||||
}
|
||||
|
||||
func normalizeListLimit(limit int) int {
|
||||
if limit <= 0 || limit > 100 {
|
||||
return 100
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func normalizeKey(key Key) (Key, error) {
|
||||
key.Collection = strings.TrimSpace(key.Collection)
|
||||
key.Key = strings.TrimSpace(key.Key)
|
||||
key.UserID = strings.TrimSpace(key.UserID)
|
||||
if key.Collection == "" || key.Key == "" || !validKeyPart(key.Collection) || !validKeyPart(key.Key) || !validKeyPart(key.UserID) {
|
||||
return Key{}, ErrInvalidKey
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func validKeyPart(value string) bool {
|
||||
return !strings.ContainsAny(value, "/\x00")
|
||||
}
|
||||
|
||||
func normalizePermission(value int) int {
|
||||
switch value {
|
||||
case PermissionNoAccess, PermissionOwner, PermissionPublic:
|
||||
return value
|
||||
default:
|
||||
return PermissionOwner
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIndex(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 {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchIndex(object Object, filters map[string]string) bool {
|
||||
filters = normalizeIndex(filters)
|
||||
if len(filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for key, value := range filters {
|
||||
if object.Index[key] != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func versionFor(object Object) string {
|
||||
indexJSON, _ := json.Marshal(normalizeIndex(object.Index))
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{
|
||||
objectID(object.Key),
|
||||
string(object.Value),
|
||||
string(indexJSON),
|
||||
object.UpdatedAt.Format(time.RFC3339Nano),
|
||||
}, "\x1f")))
|
||||
return hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func objectID(key Key) string {
|
||||
key, _ = normalizeKey(key)
|
||||
return key.Collection + "/" + key.UserID + "/" + key.Key
|
||||
}
|
||||
|
||||
func cloneObject(object Object) Object {
|
||||
object.Value = append(json.RawMessage(nil), object.Value...)
|
||||
object.Index = normalizeIndex(object.Index)
|
||||
return object
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
Reference in new issue
Block a user