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

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

577 lines
15 KiB
Go

package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
"longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
var ErrDatabaseRequired = errors.New("sqlite repository database is required")
const (
checkpointModePassive = "PASSIVE"
checkpointModeTruncate = "TRUNCATE"
periodicSyncInterval = time.Minute
sqliteSnapshotSyncSourcePeriodic = "periodic"
sqliteSnapshotSyncSourceStop = "stop"
)
var periodicSyncEvery = periodicSyncInterval
type checkpointResult struct {
Busy int
LogFrames int
CheckpointedFrames int
}
type Snapshot struct {
Driver string `json:"driver"`
Path string `json:"path"`
Started bool `json:"started"`
StateVersion int `json:"state_version"`
RecordCount int `json:"record_count"`
Dirty bool `json:"dirty"`
SyncCount uint64 `json:"sync_count"`
LastSyncAt string `json:"last_sync_at,omitempty"`
LastSyncError string `json:"last_sync_error,omitempty"`
}
type Component struct {
mu sync.RWMutex
mutationGate chan struct{}
db *sql.DB
path string
runtime *memory.Runtime
persisted memory.State
group repository.Group
started bool
dirty bool
syncCancel context.CancelFunc
syncDone chan struct{}
syncCount uint64
lastSyncAt time.Time
lastSyncError string
}
func NewComponent(database *sql.DB, path string) *Component {
return &Component{
db: database,
path: strings.TrimSpace(path),
mutationGate: make(chan struct{}, 1),
}
}
func (c *Component) acquireMutation(ctx context.Context) error {
if c == nil || c.mutationGate == nil {
return ErrDatabaseRequired
}
if ctx == nil {
ctx = context.Background()
}
select {
case c.mutationGate <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (c *Component) releaseMutation() {
if c != nil && c.mutationGate != nil {
<-c.mutationGate
}
}
func (c *Component) Name() string { return "dnf-repository-sqlite" }
func (c *Component) Preflight(ctx context.Context) error {
if c == nil || c.db == nil {
return ErrDatabaseRequired
}
if ctx == nil {
ctx = context.Background()
}
return c.db.PingContext(ctx)
}
func (c *Component) Start(ctx context.Context) error {
if c == nil || c.db == nil {
return ErrDatabaseRequired
}
if ctx == nil {
ctx = context.Background()
}
c.mu.Lock()
defer c.mu.Unlock()
if c.started {
return nil
}
if err := configureDatabase(ctx, c.db); err != nil {
return err
}
if _, err := c.db.ExecContext(ctx, schemaSQL); err != nil {
return fmt.Errorf("create sqlite repository schema: %w", err)
}
state, err := loadState(ctx, c.db)
if err != nil {
return err
}
runtime, err := memory.NewRuntimeFromState(ctx, state)
if err != nil {
return fmt.Errorf("restore sqlite repository state: %w", err)
}
c.runtime = runtime
c.group = buildGroup(c)
c.started = true
c.dirty = false
persisted := runtime.Snapshot()
if err := persistTransition(ctx, c.db, state, persisted); err != nil {
c.started = false
c.group = repository.Group{}
c.runtime = nil
return err
}
c.persisted = persisted
c.startPeriodicSyncLocked()
return nil
}
// ReplaceState validates and atomically replaces every domain record in a
// SQLite repository. It is intended for explicit migration and release-save
// export tools; normal game writes continue to flow through Component.
func ReplaceState(ctx context.Context, database *sql.DB, state memory.State) error {
if database == nil {
return ErrDatabaseRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
if state.Version == 0 {
state.Version = memory.StateVersion
}
if _, err := memory.NewRuntimeFromState(ctx, state); err != nil {
return fmt.Errorf("validate sqlite replacement state: %w", err)
}
if err := configureDatabase(ctx, database); err != nil {
return err
}
if _, err := database.ExecContext(ctx, schemaSQL); err != nil {
return fmt.Errorf("create sqlite repository schema: %w", err)
}
before, err := loadState(ctx, database)
if err != nil {
return err
}
return persistTransition(ctx, database, before, state)
}
func configureDatabase(ctx context.Context, database *sql.DB) error {
for _, pragma := range []string{
`PRAGMA foreign_keys = ON`,
`PRAGMA journal_mode = WAL`,
`PRAGMA synchronous = FULL`,
`PRAGMA busy_timeout = 5000`,
} {
if _, err := database.ExecContext(ctx, pragma); err != nil {
return fmt.Errorf("configure sqlite repository: %w", err)
}
}
return nil
}
func (c *Component) Stop(ctx context.Context) error {
if c == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
if err := c.stopPeriodicSync(ctx); err != nil {
return err
}
if err := c.acquireMutation(ctx); err != nil {
return err
}
defer c.releaseMutation()
c.mu.Lock()
var syncErr error
if c.started && c.runtime != nil {
syncErr = c.syncRuntimeLocked(ctx, checkpointModeTruncate, sqliteSnapshotSyncSourceStop)
}
c.started = false
c.dirty = false
c.group = repository.Group{}
c.runtime = nil
c.persisted = memory.State{}
c.mu.Unlock()
return syncErr
}
func (c *Component) Group() (repository.Group, bool) {
if c == nil {
return repository.Group{}, false
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.group, c.started
}
func (c *Component) Snapshot() Snapshot {
if c == nil {
return Snapshot{Driver: "sqlite"}
}
c.mu.RLock()
defer c.mu.RUnlock()
snapshot := Snapshot{
Driver: "sqlite",
Path: c.path,
Started: c.started,
StateVersion: memory.StateVersion,
Dirty: c.dirty,
SyncCount: c.syncCount,
LastSyncError: c.lastSyncError,
}
if !c.lastSyncAt.IsZero() {
snapshot.LastSyncAt = c.lastSyncAt.UTC().Format(time.RFC3339Nano)
}
if c.runtime != nil {
state := c.runtime.Snapshot()
snapshot.RecordCount = len(state.Accounts) + len(state.AccountInventories) + len(state.Characters) + len(state.Inventories) + len(state.Equipment) + len(state.Pets) + len(state.Quests) + len(state.Skills) + len(state.DungeonPermissions) + len(state.HellPartyPermissions) + len(state.PacketTemplates) + len(state.Settings) + len(state.Mailboxes) + len(state.EpicBooks) + len(state.UnitedFriends)
}
return snapshot
}
func (c *Component) check(ctx context.Context) error {
if c == nil || c.db == nil {
return ErrDatabaseRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
if err := c.db.PingContext(ctx); err != nil {
return err
}
c.mu.RLock()
defer c.mu.RUnlock()
if c.lastSyncError != "" {
return fmt.Errorf("sqlite background save is unhealthy: %s", c.lastSyncError)
}
return nil
}
func (c *Component) read(ctx context.Context, apply func(repository.Group) error) error {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
c.mu.RLock()
defer c.mu.RUnlock()
if !c.started || c.runtime == nil {
return ErrDatabaseRequired
}
return apply(c.runtime.Group())
}
func (c *Component) mutateAsync(ctx context.Context, apply func(repository.Group) error) error {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
if err := c.acquireMutation(ctx); err != nil {
return err
}
defer c.releaseMutation()
c.mu.RLock()
if !c.started || c.runtime == nil {
c.mu.RUnlock()
return ErrDatabaseRequired
}
group := c.runtime.Group()
c.mu.RUnlock()
if err := apply(group); err != nil {
return err
}
c.mu.Lock()
c.dirty = true
c.mu.Unlock()
return nil
}
func (c *Component) mutateSync(ctx context.Context, apply func(repository.Group) error) error {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return err
}
if err := c.acquireMutation(ctx); err != nil {
return err
}
defer c.releaseMutation()
c.mu.RLock()
if !c.started || c.runtime == nil {
c.mu.RUnlock()
return ErrDatabaseRequired
}
before := c.runtime.Snapshot()
persistedBefore := c.persisted
dirtyBefore := c.dirty
group := c.runtime.Group()
c.mu.RUnlock()
if err := apply(group); err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
after := c.runtime.Snapshot()
if err := persistTransition(ctx, c.db, persistedBefore, after); err != nil {
restored, restoreErr := memory.NewRuntimeFromState(context.Background(), before)
if restoreErr == nil {
c.runtime = restored
}
c.dirty = dirtyBefore
return errors.Join(err, restoreErr)
}
c.persisted = after
c.dirty = false
return nil
}
// mutateCharacterAssetsSync keeps the same durable, all-or-nothing contract
// as mutateSync, but its persistence boundary is deliberately the three
// records a CharacterAssetUnitOfWork is allowed to mutate. In particular a
// dungeon pickup must not serialize/checksum every quest, mailbox and other
// character record merely to add one stack to the selected character's bag.
//
// The component-wide mutation gate remains necessary: the in-memory aggregate
// and SQLite have one authoritative writer. Narrowing the persisted delta
// shortens that gate's hold time without weakening transaction ordering.
func (c *Component) mutateCharacterAssetsSync(
ctx context.Context,
characterID string,
apply func(repository.CharacterRepository, repository.InventoryRepository, repository.EquipmentRepository) error,
) error {
characterID = strings.TrimSpace(characterID)
if characterID == "" || apply == nil {
return repository.ErrCharacterAssetTransactionUnavailable
}
return c.mutateRecordScopeSync(ctx, []scopedRecordRef{
{kind: kindCharacter, key: characterID},
{kind: kindInventory, key: characterID},
{kind: kindEquipment, key: characterID},
}, func(group repository.Group) error {
return group.CharacterAssets.WithinCharacterAssets(ctx, characterID, apply)
})
}
type characterAssetsSnapshot struct {
character repository.CharacterRecord
characterFound bool
inventory repository.InventoryRecord
inventoryFound bool
equipment repository.EquipmentRecord
equipmentFound bool
}
func loadCharacterAssetsSnapshot(ctx context.Context, group repository.Group, characterID string) (characterAssetsSnapshot, error) {
character, characterFound, err := group.Character.Load(ctx, characterID)
if err != nil {
return characterAssetsSnapshot{}, err
}
inventory, inventoryFound, err := group.Inventory.Load(ctx, characterID)
if err != nil {
return characterAssetsSnapshot{}, err
}
equipment, equipmentFound, err := group.Equipment.Load(ctx, characterID)
if err != nil {
return characterAssetsSnapshot{}, err
}
return characterAssetsSnapshot{
character: repository.CloneCharacter(character),
characterFound: characterFound,
inventory: repository.CloneInventory(inventory),
inventoryFound: inventoryFound,
equipment: repository.CloneEquipment(equipment),
equipmentFound: equipmentFound,
}, nil
}
func applyCharacterAssetsSnapshotToState(state *memory.State, snapshot characterAssetsSnapshot) {
if state == nil {
return
}
if snapshot.characterFound {
state.Characters = replaceCharacterStateRecord(state.Characters, snapshot.character)
}
if snapshot.inventoryFound {
state.Inventories = replaceInventoryStateRecord(state.Inventories, snapshot.inventory)
}
if snapshot.equipmentFound {
state.Equipment = replaceEquipmentStateRecord(state.Equipment, snapshot.equipment)
}
}
func replaceCharacterStateRecord(records []repository.CharacterRecord, record repository.CharacterRecord) []repository.CharacterRecord {
for index := range records {
if records[index].CharacterID == record.CharacterID {
records[index] = repository.CloneCharacter(record)
return records
}
}
return append(records, repository.CloneCharacter(record))
}
func replaceInventoryStateRecord(records []repository.InventoryRecord, record repository.InventoryRecord) []repository.InventoryRecord {
for index := range records {
if records[index].CharacterID == record.CharacterID {
records[index] = repository.CloneInventory(record)
return records
}
}
return append(records, repository.CloneInventory(record))
}
func replaceEquipmentStateRecord(records []repository.EquipmentRecord, record repository.EquipmentRecord) []repository.EquipmentRecord {
for index := range records {
if records[index].CharacterID == record.CharacterID {
records[index] = repository.CloneEquipment(record)
return records
}
}
return append(records, repository.CloneEquipment(record))
}
func (c *Component) now() time.Time { return time.Now().UTC() }
func (c *Component) startPeriodicSyncLocked() {
if c == nil || c.syncCancel != nil || periodicSyncEvery <= 0 {
return
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
c.syncCancel = cancel
c.syncDone = done
go c.runPeriodicSync(ctx, done, periodicSyncEvery)
}
func (c *Component) runPeriodicSync(ctx context.Context, done chan<- struct{}, interval time.Duration) {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = c.syncRuntime(ctx, checkpointModePassive, sqliteSnapshotSyncSourcePeriodic)
}
}
}
func (c *Component) stopPeriodicSync(ctx context.Context) error {
c.mu.Lock()
cancel := c.syncCancel
done := c.syncDone
c.syncCancel = nil
c.syncDone = nil
c.mu.Unlock()
if cancel == nil || done == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("stop sqlite periodic save: %w", ctx.Err())
}
}
func (c *Component) syncRuntime(ctx context.Context, checkpointMode, source string) error {
if ctx == nil {
ctx = context.Background()
}
if err := c.acquireMutation(ctx); err != nil {
return err
}
defer c.releaseMutation()
c.mu.Lock()
defer c.mu.Unlock()
if !c.started || c.runtime == nil {
return ErrDatabaseRequired
}
return c.syncRuntimeLocked(ctx, checkpointMode, source)
}
func (c *Component) syncRuntimeLocked(ctx context.Context, checkpointMode, source string) error {
var err error
if c.dirty {
state := c.runtime.Snapshot()
err = persistTransition(ctx, c.db, c.persisted, state)
if err == nil {
c.persisted = state
}
if err == nil {
c.dirty = false
}
}
if err == nil {
_, err = checkpointDatabase(ctx, c.db, checkpointMode)
}
c.syncCount++
c.lastSyncAt = time.Now().UTC()
if err != nil {
c.lastSyncError = fmt.Sprintf("%s: %v", source, err)
return fmt.Errorf("%s sqlite repository save: %w", source, err)
}
c.lastSyncError = ""
return nil
}
func checkpointDatabase(ctx context.Context, database *sql.DB, mode string) (checkpointResult, error) {
if database == nil {
return checkpointResult{}, ErrDatabaseRequired
}
mode = strings.ToUpper(strings.TrimSpace(mode))
if mode != checkpointModePassive && mode != checkpointModeTruncate {
return checkpointResult{}, fmt.Errorf("unsupported sqlite checkpoint mode %q", mode)
}
var result checkpointResult
err := database.QueryRowContext(ctx, "PRAGMA wal_checkpoint("+mode+")").Scan(
&result.Busy,
&result.LogFrames,
&result.CheckpointedFrames,
)
if err != nil {
return checkpointResult{}, fmt.Errorf("checkpoint sqlite repository: %w", err)
}
if result.Busy != 0 {
return result, fmt.Errorf(
"checkpoint sqlite repository is busy (log_frames=%d checkpointed_frames=%d)",
result.LogFrames,
result.CheckpointedFrames,
)
}
return result, nil
}