DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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