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