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,308 @@
// 本文件实现 DNF 穿戴装备仓储的 MySQL 字段化读写。
package mysql
import (
"context"
"database/sql"
"longheng.io/server/internal/modules/dnf/repository"
)
const mysqlEquipmentTable = "equipments"
type mysqlEquipmentStore struct {
mysqlStoreBase
}
// Load 按角色 ID 从 MySQL 读取穿戴装备记录。
func (s *mysqlEquipmentStore) Load(ctx context.Context, characterID string) (repository.EquipmentRecord, bool, error) {
table, err := s.router.readTable(mysqlEquipmentTable, characterID)
if err != nil {
return repository.EquipmentRecord{}, false, err
}
entriesTable, err := s.router.readTable(mysqlEquipmentEntriesTable, characterID)
if err != nil {
return repository.EquipmentRecord{}, false, err
}
extraTable, err := s.router.readTable(mysqlEquipmentExtraTable, characterID)
if err != nil {
return repository.EquipmentRecord{}, false, err
}
query := s.router.selectQuery("SELECT character_id, updated_at FROM " + table + " WHERE character_id = ?")
var record repository.EquipmentRecord
var updatedAt sql.NullTime
err = s.router.db.QueryRowContext(ctx, query, characterID).Scan(
&record.CharacterID,
&updatedAt,
)
if err != nil {
ok, scanErr := scanErr(err)
return repository.EquipmentRecord{}, ok, scanErr
}
record.Entries, err = loadEquipmentEntries(ctx, s.router, entriesTable, extraTable, characterID)
if err != nil {
return repository.EquipmentRecord{}, false, err
}
record.UpdatedAt = scanTime(updatedAt)
return repository.CloneEquipment(record), true, nil
}
// Save 保存完整穿戴装备记录。
func (s *mysqlEquipmentStore) Save(ctx context.Context, record repository.EquipmentRecord) error {
return s.SaveFields(ctx, record, repository.AllEquipmentFields()...)
}
// SaveFields 保存穿戴装备 dirty 字段。
func (s *mysqlEquipmentStore) SaveFields(ctx context.Context, record repository.EquipmentRecord, fields ...repository.EquipmentField) error {
characterID, err := requireRecordKey(repository.EquipmentKey, record, "equipment")
if err != nil {
return err
}
fields = repository.EquipmentFields.Normalize(fields)
if len(fields) == 0 {
return nil
}
table, err := s.router.writeTable(mysqlEquipmentTable, characterID)
if err != nil {
return err
}
entriesTable, err := s.router.writeTable(mysqlEquipmentEntriesTable, characterID)
if err != nil {
return err
}
extraTable, err := s.router.writeTable(mysqlEquipmentExtraTable, characterID)
if err != nil {
return err
}
columns := []string{"character_id", "updated_at"}
args := []any{characterID, timeOrNow(record.UpdatedAt, s.router.now)}
updates := []string{updateValue("updated_at")}
saveEntries := false
for _, field := range fields {
if field == repository.EquipmentFieldEntries {
saveEntries = true
}
}
return withMySQLWriteExecutor(ctx, s.router.db, func(database SQLDB) error {
if _, execErr := database.ExecContext(ctx, buildUpsert(table, columns, updates), args...); execErr != nil {
return execErr
}
if !saveEntries {
return nil
}
return replaceEquipmentEntries(ctx, database, entriesTable, extraTable, characterID, record.Entries)
})
}
func loadEquipmentEntries(
ctx context.Context,
router mysqlRouter,
entriesTable, extraTable, characterID string,
) (map[string]repository.EquipmentEntry, error) {
query := router.selectQuery("SELECT entry_key, slot_index, item_id, bind_flag, expire_at, raw_entry FROM " + entriesTable + " WHERE character_id = ? ORDER BY entry_key")
rows, err := router.db.QueryContext(ctx, query, characterID)
if err != nil {
return nil, err
}
var entries map[string]repository.EquipmentEntry
for rows.Next() {
var key string
var entry repository.EquipmentEntry
var expireAt sql.NullTime
var rawEntry []byte
if err := rows.Scan(&key, &entry.SlotIndex, &entry.ItemID, &entry.Bind, &expireAt, &rawEntry); err != nil {
rows.Close()
return nil, err
}
entry.ExpireAt = scanTime(expireAt)
entry.RawEntry = append([]byte(nil), rawEntry...)
if entries == nil {
entries = make(map[string]repository.EquipmentEntry)
}
entries[key] = entry
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
extras, err := router.db.QueryContext(ctx, router.selectQuery("SELECT entry_key, extra_key, extra_value FROM "+extraTable+" WHERE character_id = ? ORDER BY entry_key, extra_key"), characterID)
if err != nil {
return nil, err
}
defer extras.Close()
for extras.Next() {
var entryKey, extraKey, extraValue string
if err := extras.Scan(&entryKey, &extraKey, &extraValue); err != nil {
return nil, err
}
entry, ok := entries[entryKey]
if !ok {
continue
}
if entry.Extra == nil {
entry.Extra = make(map[string]string)
}
entry.Extra[extraKey] = extraValue
entries[entryKey] = entry
}
if err := extras.Err(); err != nil {
return nil, err
}
return entries, nil
}
// loadEquipmentEntriesForUpdate 在写者的事务里读出并锁住已存的条目,
// 返回的形态与 loadEquipmentEntries 相同。
//
// 移植上游 c0ebdce。FOR UPDATE 直接写在查询里(不走 router.selectQuery,
// 那只在 lockReads 时才加锁)—— 否则读写分离下会读到旧行、误判差异。
func loadEquipmentEntriesForUpdate(
ctx context.Context,
database SQLDB,
entriesTable, extraTable, characterID string,
) (map[string]repository.EquipmentEntry, error) {
rows, err := database.QueryContext(ctx, "SELECT entry_key, slot_index, item_id, bind_flag, expire_at, raw_entry FROM "+entriesTable+" WHERE character_id = ? ORDER BY entry_key FOR UPDATE", characterID)
if err != nil {
return nil, err
}
entries := make(map[string]repository.EquipmentEntry)
for rows.Next() {
var key string
var entry repository.EquipmentEntry
var expireAt sql.NullTime
var rawEntry []byte
if err := rows.Scan(&key, &entry.SlotIndex, &entry.ItemID, &entry.Bind, &expireAt, &rawEntry); err != nil {
rows.Close()
return nil, err
}
entry.ExpireAt = scanTime(expireAt)
entry.RawEntry = append([]byte(nil), rawEntry...)
entries[key] = entry
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
extras, err := database.QueryContext(ctx, "SELECT entry_key, extra_key, extra_value FROM "+extraTable+" WHERE character_id = ? ORDER BY entry_key, extra_key FOR UPDATE", characterID)
if err != nil {
return nil, err
}
defer extras.Close()
for extras.Next() {
var entryKey, extraKey, extraValue string
if err := extras.Scan(&entryKey, &extraKey, &extraValue); err != nil {
return nil, err
}
entry, ok := entries[entryKey]
if !ok {
continue
}
if entry.Extra == nil {
entry.Extra = make(map[string]string)
}
entry.Extra[extraKey] = extraValue
entries[entryKey] = entry
}
return entries, extras.Err()
}
func replaceEquipmentEntries(
ctx context.Context,
database SQLDB,
entriesTable, extraTable, characterID string,
entries map[string]repository.EquipmentEntry,
) error {
// 与锁住的持久行做差异,只碰真正变化的条目;理由同 replaceItemStackCollection
// (上游 c0ebdce)。
current, err := loadEquipmentEntriesForUpdate(ctx, database, entriesTable, extraTable, characterID)
if err != nil {
return err
}
writeKeys := make([]string, 0)
for _, key := range sortedStringKeys(entries) {
if old, ok := current[key]; !ok || !equipmentEntryStoredEqual(old, entries[key]) {
writeKeys = append(writeKeys, key)
}
}
deleteKeys := append([]string(nil), writeKeys...)
for _, key := range sortedStringKeys(current) {
if _, ok := entries[key]; !ok {
deleteKeys = append(deleteKeys, key)
}
}
owner := []any{characterID}
if err := deleteMySQLEntryKeys(ctx, database, extraTable, "character_id = ?", owner, deleteKeys); err != nil {
return err
}
if err := deleteMySQLEntryKeys(ctx, database, entriesTable, "character_id = ?", owner, deleteKeys); err != nil {
return err
}
// 改前是「每个条目一条 ExecContext + 每个附加键再一条」,按 128 行一批改写后
// 语句数与条目数无关。与背包 / 任务 / roster 路径用的同一个 buildMySQLBulkInsert。
//
// 两张表的写入顺序由「条目与其附加键交错」变为「先全部条目、再全部附加键」:
// 它们分属不同的表且键在两处都唯一,因此结果集完全一致;整段仍在该替换事务内,
// 中途失败由事务回滚,不产生半写状态。
entryColumns := []string{
"character_id", "entry_key", "slot_index", "item_id", "bind_flag", "expire_at", "raw_entry",
}
extraColumns := []string{"character_id", "entry_key", "extra_key", "extra_value"}
// 只写差异出来的条目(上游 c0ebdce)。writeKeys 由 sortedStringKeys(entries) 过滤而来,
// 所以顺序与原来的全量写一致。
entryKeys := writeKeys
for start := 0; start < len(entryKeys); start += mysqlReplaceBatchSize {
end := min(start+mysqlReplaceBatchSize, len(entryKeys))
chunk := entryKeys[start:end]
args := make([]any, 0, len(chunk)*len(entryColumns))
for _, entryKey := range chunk {
entry := entries[entryKey]
args = append(args,
characterID, entryKey, entry.SlotIndex, entry.ItemID, entry.Bind,
sqlTime(entry.ExpireAt), entry.RawEntry)
}
if _, err := database.ExecContext(
ctx,
buildMySQLBulkInsert(entriesTable, entryColumns, len(chunk)),
args...,
); err != nil {
return err
}
}
type equipmentExtraRow struct {
entryKey string
extraKey string
value any
}
extras := make([]equipmentExtraRow, 0, len(entryKeys))
for _, entryKey := range entryKeys {
entry := entries[entryKey]
for _, extraKey := range sortedStringKeys(entry.Extra) {
extras = append(extras, equipmentExtraRow{
entryKey: entryKey,
extraKey: extraKey,
value: entry.Extra[extraKey],
})
}
}
for start := 0; start < len(extras); start += mysqlReplaceBatchSize {
end := min(start+mysqlReplaceBatchSize, len(extras))
chunk := extras[start:end]
args := make([]any, 0, len(chunk)*len(extraColumns))
for _, row := range chunk {
args = append(args, characterID, row.entryKey, row.extraKey, row.value)
}
if _, err := database.ExecContext(
ctx,
buildMySQLBulkInsert(extraTable, extraColumns, len(chunk)),
args...,
); err != nil {
return err
}
}
return nil
}