服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
454 lines
13 KiB
Go
454 lines
13 KiB
Go
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")
|
|
}
|