服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const mysqlMailboxTable = "mailboxes"
|
|
|
|
type mysqlMailboxStore struct {
|
|
mysqlStoreBase
|
|
}
|
|
|
|
// Load reads one character mailbox from MySQL.
|
|
func (s *mysqlMailboxStore) Load(ctx context.Context, characterID string) (repository.MailboxRecord, bool, error) {
|
|
table, err := s.router.readTable(mysqlMailboxTable, characterID)
|
|
if err != nil {
|
|
return repository.MailboxRecord{}, false, err
|
|
}
|
|
mailsTable, err := s.router.readTable(mysqlMailsTable, characterID)
|
|
if err != nil {
|
|
return repository.MailboxRecord{}, false, err
|
|
}
|
|
metadataTable, err := s.router.readTable(mysqlMailMetadataTable, characterID)
|
|
if err != nil {
|
|
return repository.MailboxRecord{}, false, err
|
|
}
|
|
attachmentsTable, err := s.router.readTable(mysqlMailAttachmentsTable, characterID)
|
|
if err != nil {
|
|
return repository.MailboxRecord{}, false, err
|
|
}
|
|
attachmentExtraTable, err := s.router.readTable(mysqlMailAttachmentExtraTable, characterID)
|
|
if err != nil {
|
|
return repository.MailboxRecord{}, false, err
|
|
}
|
|
query := s.router.selectQuery("SELECT character_id, updated_at FROM " + table + " WHERE character_id = ?")
|
|
var record repository.MailboxRecord
|
|
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.MailboxRecord{}, ok, scanErr
|
|
}
|
|
record.Mails, err = loadMails(
|
|
ctx,
|
|
s.router,
|
|
mailsTable,
|
|
metadataTable,
|
|
attachmentsTable,
|
|
attachmentExtraTable,
|
|
characterID,
|
|
)
|
|
if err != nil {
|
|
return repository.MailboxRecord{}, false, err
|
|
}
|
|
record.UpdatedAt = scanTime(updatedAt)
|
|
return repository.CloneMailbox(record), true, nil
|
|
}
|
|
|
|
// Save persists the whole mailbox aggregate.
|
|
func (s *mysqlMailboxStore) Save(ctx context.Context, record repository.MailboxRecord) error {
|
|
return s.SaveFields(ctx, record, repository.AllMailboxFields()...)
|
|
}
|
|
|
|
// SaveFields persists selected mailbox fields.
|
|
func (s *mysqlMailboxStore) SaveFields(ctx context.Context, record repository.MailboxRecord, fields ...repository.MailboxField) error {
|
|
characterID, err := requireRecordKey(repository.MailboxKey, record, "mailbox")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fields = repository.MailboxFields.Normalize(fields)
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
table, err := s.router.writeTable(mysqlMailboxTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mailsTable, err := s.router.writeTable(mysqlMailsTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
metadataTable, err := s.router.writeTable(mysqlMailMetadataTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachmentsTable, err := s.router.writeTable(mysqlMailAttachmentsTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachmentExtraTable, err := s.router.writeTable(mysqlMailAttachmentExtraTable, 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")}
|
|
saveMails := false
|
|
for _, field := range fields {
|
|
if field == repository.MailboxFieldMails {
|
|
saveMails = 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 !saveMails {
|
|
return nil
|
|
}
|
|
return replaceMails(
|
|
ctx,
|
|
database,
|
|
mailsTable,
|
|
metadataTable,
|
|
attachmentsTable,
|
|
attachmentExtraTable,
|
|
characterID,
|
|
record.Mails,
|
|
)
|
|
})
|
|
}
|
|
|
|
func loadMails(
|
|
ctx context.Context,
|
|
router mysqlRouter,
|
|
mailsTable, metadataTable, attachmentsTable, attachmentExtraTable, characterID string,
|
|
) (map[string]repository.MailRecord, error) {
|
|
query := router.selectQuery("SELECT mail_id, sender_character_id, sender_name, recipient_character_id, recipient_name, title, body, gold, read_flag, claimed_flag, deleted_flag, created_at, expire_at FROM " + mailsTable + " WHERE character_id = ? ORDER BY mail_id")
|
|
rows, err := router.db.QueryContext(ctx, query, characterID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var mails map[string]repository.MailRecord
|
|
for rows.Next() {
|
|
var mail repository.MailRecord
|
|
var createdAt, expireAt sql.NullTime
|
|
if err := rows.Scan(
|
|
&mail.MailID,
|
|
&mail.SenderCharacterID,
|
|
&mail.SenderName,
|
|
&mail.RecipientCharacterID,
|
|
&mail.RecipientName,
|
|
&mail.Title,
|
|
&mail.Body,
|
|
&mail.Gold,
|
|
&mail.Read,
|
|
&mail.Claimed,
|
|
&mail.Deleted,
|
|
&createdAt,
|
|
&expireAt,
|
|
); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
mail.CreatedAt = scanTime(createdAt)
|
|
mail.ExpireAt = scanTime(expireAt)
|
|
if mails == nil {
|
|
mails = make(map[string]repository.MailRecord)
|
|
}
|
|
mails[mail.MailID] = mail
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metadata, err := router.db.QueryContext(
|
|
ctx,
|
|
router.selectQuery("SELECT mail_id, metadata_key, metadata_value FROM "+metadataTable+" WHERE character_id = ? ORDER BY mail_id, metadata_key"),
|
|
characterID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for metadata.Next() {
|
|
var mailID, key, value string
|
|
if err := metadata.Scan(&mailID, &key, &value); err != nil {
|
|
metadata.Close()
|
|
return nil, err
|
|
}
|
|
mail, ok := mails[mailID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if mail.Metadata == nil {
|
|
mail.Metadata = make(map[string]string)
|
|
}
|
|
mail.Metadata[key] = value
|
|
mails[mailID] = mail
|
|
}
|
|
if err := metadata.Err(); err != nil {
|
|
metadata.Close()
|
|
return nil, err
|
|
}
|
|
if err := metadata.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
attachments, err := router.db.QueryContext(
|
|
ctx,
|
|
router.selectQuery("SELECT mail_id, attachment_index, item_id, item_count, bind_flag, expire_at, raw_entry FROM "+attachmentsTable+" WHERE character_id = ? ORDER BY mail_id, attachment_index"),
|
|
characterID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for attachments.Next() {
|
|
var mailID string
|
|
var index int
|
|
var attachment repository.MailAttachment
|
|
var expireAt sql.NullTime
|
|
var rawEntry []byte
|
|
if err := attachments.Scan(&mailID, &index, &attachment.ItemID, &attachment.Count, &attachment.Bind, &expireAt, &rawEntry); err != nil {
|
|
attachments.Close()
|
|
return nil, err
|
|
}
|
|
mail, ok := mails[mailID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
attachment.ExpireAt = scanTime(expireAt)
|
|
attachment.RawEntry = append([]byte(nil), rawEntry...)
|
|
for len(mail.Attachments) <= index {
|
|
mail.Attachments = append(mail.Attachments, repository.MailAttachment{})
|
|
}
|
|
mail.Attachments[index] = attachment
|
|
mails[mailID] = mail
|
|
}
|
|
if err := attachments.Err(); err != nil {
|
|
attachments.Close()
|
|
return nil, err
|
|
}
|
|
if err := attachments.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
extras, err := router.db.QueryContext(
|
|
ctx,
|
|
router.selectQuery("SELECT mail_id, attachment_index, extra_key, extra_value FROM "+attachmentExtraTable+" WHERE character_id = ? ORDER BY mail_id, attachment_index, extra_key"),
|
|
characterID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer extras.Close()
|
|
for extras.Next() {
|
|
var mailID, key, value string
|
|
var index int
|
|
if err := extras.Scan(&mailID, &index, &key, &value); err != nil {
|
|
return nil, err
|
|
}
|
|
mail, ok := mails[mailID]
|
|
if !ok || index < 0 || index >= len(mail.Attachments) {
|
|
continue
|
|
}
|
|
if mail.Attachments[index].Extra == nil {
|
|
mail.Attachments[index].Extra = make(map[string]string)
|
|
}
|
|
mail.Attachments[index].Extra[key] = value
|
|
mails[mailID] = mail
|
|
}
|
|
if err := extras.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return mails, nil
|
|
}
|
|
|
|
func replaceMails(
|
|
ctx context.Context,
|
|
database SQLDB,
|
|
mailsTable, metadataTable, attachmentsTable, attachmentExtraTable, characterID string,
|
|
mails map[string]repository.MailRecord,
|
|
) error {
|
|
for _, table := range []string{attachmentExtraTable, attachmentsTable, metadataTable, mailsTable} {
|
|
if _, err := database.ExecContext(ctx, "DELETE FROM "+table+" WHERE character_id = ?", characterID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// 改前是四层嵌套的逐行 ExecContext:每封邮件 1 条 + 每个元数据键 1 条 +
|
|
// 每个附件 1 条 + 每个附件附加键 1 条。邮箱里上百封邮件时,一次存档就是
|
|
// 数百条语句。按 128 行一批后,语句数只与「表数」有关,与邮件数无关。
|
|
//
|
|
// 四张表互不相同,因此把「按邮件交错写四张表」改为「逐表批量写」结果集一致;
|
|
// 每张表内部仍按 sortedStringKeys 的排序与原始下标(attachment_index)写入。
|
|
mailColumns := []string{
|
|
"character_id", "mail_id", "sender_character_id", "sender_name",
|
|
"recipient_character_id", "recipient_name", "title", "body", "gold",
|
|
"read_flag", "claimed_flag", "deleted_flag", "created_at", "expire_at",
|
|
}
|
|
metadataColumns := []string{"character_id", "mail_id", "metadata_key", "metadata_value"}
|
|
attachmentColumns := []string{
|
|
"character_id", "mail_id", "attachment_index", "item_id",
|
|
"item_count", "bind_flag", "expire_at", "raw_entry",
|
|
}
|
|
extraColumns := []string{
|
|
"character_id", "mail_id", "attachment_index", "extra_key", "extra_value",
|
|
}
|
|
mailArgs := make([]any, 0, len(mails)*len(mailColumns))
|
|
metadataArgs := make([]any, 0, len(mails)*4*len(metadataColumns))
|
|
attachmentArgs := make([]any, 0, len(mails)*4*len(attachmentColumns))
|
|
extraArgs := make([]any, 0, len(mails)*8*len(extraColumns))
|
|
for _, mailID := range sortedStringKeys(mails) {
|
|
mail := mails[mailID]
|
|
mailArgs = append(mailArgs,
|
|
characterID,
|
|
mailID,
|
|
mail.SenderCharacterID,
|
|
mail.SenderName,
|
|
mail.RecipientCharacterID,
|
|
mail.RecipientName,
|
|
mail.Title,
|
|
mail.Body,
|
|
mail.Gold,
|
|
mail.Read,
|
|
mail.Claimed,
|
|
mail.Deleted,
|
|
sqlTime(mail.CreatedAt),
|
|
sqlTime(mail.ExpireAt),
|
|
)
|
|
for _, key := range sortedStringKeys(mail.Metadata) {
|
|
metadataArgs = append(metadataArgs, characterID, mailID, key, mail.Metadata[key])
|
|
}
|
|
for index, attachment := range mail.Attachments {
|
|
attachmentArgs = append(attachmentArgs,
|
|
characterID,
|
|
mailID,
|
|
index,
|
|
attachment.ItemID,
|
|
attachment.Count,
|
|
attachment.Bind,
|
|
sqlTime(attachment.ExpireAt),
|
|
attachment.RawEntry,
|
|
)
|
|
for _, key := range sortedStringKeys(attachment.Extra) {
|
|
extraArgs = append(extraArgs, characterID, mailID, index, key, attachment.Extra[key])
|
|
}
|
|
}
|
|
}
|
|
writeMailBatch := func(table string, columns []string, args []any) error {
|
|
rows := len(args) / len(columns)
|
|
for start := 0; start < rows; start += mysqlReplaceBatchSize {
|
|
end := min(start+mysqlReplaceBatchSize, rows)
|
|
if _, err := database.ExecContext(
|
|
ctx,
|
|
buildMySQLBulkInsert(table, columns, end-start),
|
|
args[start*len(columns):end*len(columns)]...,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if err := writeMailBatch(mailsTable, mailColumns, mailArgs); err != nil {
|
|
return err
|
|
}
|
|
if err := writeMailBatch(metadataTable, metadataColumns, metadataArgs); err != nil {
|
|
return err
|
|
}
|
|
if err := writeMailBatch(attachmentsTable, attachmentColumns, attachmentArgs); err != nil {
|
|
return err
|
|
}
|
|
return writeMailBatch(attachmentExtraTable, extraColumns, extraArgs)
|
|
}
|