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,758 @@
package dnfbridge
// activity_hooks.go 把三类审计记录接到玩法主路径上,并暴露 GM 查询接口。
//
// 记录器本身(落盘/轮转/检索)在 activity_record.go;这里只负责
// "在正确的时机、用尽量全的字段" 调用它。
//
// 三条硬约束(记录是旁路,不能反过来影响玩法):
// 1. recordActivity 可从任意协程调用,永不 panic、永不阻塞在主路径上;
// 2. 记录字段取不到就留空,绝不为凑字段去改仓储/阻塞协议流程;
// 3. 角色名做了会话级缓存——聊天很频繁,不能每条消息都查一次角色表。
import (
"context"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfauction "longheng.io/server/internal/modules/dnf/auction"
)
// recordActivity 追加一条审计记录。记录器未启用(目录不可写/未启动)时直接空转。
func (s *Service) recordActivity(kind string, fields map[string]any) {
if s == nil || fields == nil {
return
}
recorder := s.records.Load()
if recorder == nil {
return
}
recorder.record(kind, fields, s.logger)
}
// activitySessionFields 组装"这个会话是谁"的公共字段。
// 三个类别都要这几项,集中一处避免各埋点写法不一致。
//
// 注意这里**不能**用 s.accountIDForSession:那个函数在开启启动器鉴权且会话未绑定
// 账号时会写日志并主动断开连接。审计埋点是旁路,绝不能因为记录一条聊天就把玩家
// 踢下线。这里只读会话上已经绑定好的账号,读不到就留空。
func (s *Service) activitySessionFields(session *gameSession) map[string]any {
fields := map[string]any{}
if session == nil {
return fields
}
if session.selectedCharacterID != 0 {
fields["character_id"] = strconv.Itoa(int(session.selectedCharacterID))
}
if name := s.activityCharacterName(session); name != "" {
fields["character_name"] = name
}
if accountID := strings.TrimSpace(session.accountID); accountID != "" {
fields["account_id"] = accountID
}
if session.channel.ID != 0 {
fields["channel_id"] = session.channel.ID
}
return fields
}
// activityCharacterName 取当前选中角色的名字,带会话级缓存。
//
// 为什么必须缓存:对话记录挂在每一次发言上,而角色名在 gameSession 里没有常驻字段,
// 只能查一次角色表。每条消息查一次库会给聊天加上一次 DB 往返,代价与收益不成比例;
// 角色名在同一次登录期间不会变,缓存到 selectedCharacterID 变化为止即可。
func (s *Service) activityCharacterName(session *gameSession) string {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return ""
}
session.activityNameMu.Lock()
if session.activityNameCharacterID == session.selectedCharacterID && session.activityName != "" {
name := session.activityName
session.activityNameMu.Unlock()
return name
}
session.activityNameMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil {
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
record, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
if err != nil || !found {
return ""
}
session.activityNameMu.Lock()
session.activityNameCharacterID = session.selectedCharacterID
session.activityName = record.Name
session.activityNameMu.Unlock()
return record.Name
}
// ================= 对话记录 =================
// chatRecordText 把聊天原文解码成可读文本。
//
// 客户端聊天正文是 GBK 字节(与角色名、NPC 文本同一套编码),直接落盘会得到乱码,
// 查记录的人就看不懂了。解不出来时原样保留字节转成的字符串:宁可留一段乱码,
// 也不要丢掉玩家到底发了什么这个事实。
func chatRecordText(message []byte) string {
if len(message) == 0 {
return ""
}
ascii := true
for _, value := range message {
if value >= 0x80 {
ascii = false
break
}
}
if ascii {
return string(message)
}
decoded, err := simplifiedchinese.GB18030.NewDecoder().Bytes(message)
if err != nil {
return string(message)
}
return string(decoded)
}
// recordChat 记一条对话。result 传 "ok" / "rejected",rejected 时 reason 必填。
//
// message 收原始字节(客户端发来的 GBK),解码在这里做,调用点不必关心编码。
// 字段刻意保留完整原文——运营/管理端查"谁在喊什么"是这类记录的主要用途,
// 截断会让它失去意义。文件轮转与保留期由记录器控制,不在这里做二次裁剪。
func (s *Service) recordChat(
session *gameSession,
mode byte,
modeName string,
message []byte,
recipients int,
itemLinks int,
result string,
reason string,
target map[string]any,
) {
if s == nil {
return
}
text := chatRecordText(message)
fields := s.activitySessionFields(session)
fields["mode"] = mode
fields["mode_name"] = modeName
fields["message"] = text
fields["message_len"] = len([]rune(text))
fields["message_bytes"] = len(message)
if result != "ok" && len(message) > 0 {
// 被拒绝的消息正文也留一份十六进制,便于核对解码是否正确。
fields["message_hex"] = hex.EncodeToString(message)
}
fields["recipients"] = recipients
fields["item_links"] = itemLinks
fields["result"] = result
if reason != "" {
fields["reason"] = reason
}
for key, value := range target {
fields[key] = value
}
s.recordActivity(recordKindChat, fields)
}
// currentTownChatModeName 聊天模式的中文名(记录里给人看,比数字好读)。
func currentTownChatModeName(mode byte) string {
switch mode {
case currentTownChatWhisperMode:
return "私聊"
case currentTownChatOneToOneMode:
return "1:1会话"
case currentTownChatPeerMenuMode:
return "好友菜单消息"
case currentTownChatMegaphoneMode:
return "服务器喇叭"
case currentTownChatChannelMegaphoneMode:
return "频道喇叭"
case currentTownChatAreaMode:
return "区域"
case currentTownChatArenaMode:
return "决斗场"
case currentTownChatPartyMode:
return "队伍"
case currentTownChatGuildMode, currentTownChatGuildLegacyMode:
return "公会"
case currentTownChatRaidMode:
return "攻坚队"
default:
return fmt.Sprintf("未知模式(%d)", mode)
}
}
// ================= 点券消费记录 =================
// recordCeraSpend 记一次点券消费。
//
// 余额前后值能拿到就填:运营最常问的是"这条消费之后他还剩多少",
// 只写扣了多少没法回答这个问题。
func (s *Service) recordCeraSpend(session *gameSession, fields map[string]any) {
if s == nil {
return
}
merged := s.activitySessionFields(session)
for key, value := range fields {
merged[key] = value
}
s.recordActivity(recordKindCera, merged)
}
// recordAlignedCeraSpend 记一条由 aligned 命令模块上报的点券消费。
//
// 目前只有金库创建/升级走这条路(模块自己不经过商城结账)。金额为 0 表示
// 这条命令没有点券消费,直接跳过——不能让"每次 op 都写一条 0 元消费"。
func (s *Service) recordAlignedCeraSpend(session *gameSession, result alignedcmd.Result) {
if s == nil || result.CeraSpent <= 0 {
return
}
fields := map[string]any{
"result": "ok",
"source": "aligned_command",
"operation": result.Operation,
"payment_mode": "点券",
"payment_name": "点券",
"cera_spent": result.CeraSpent,
"cera_after": result.CeraAfter,
"cera_before": result.CeraAfter + result.CeraSpent,
"cera_spent_note": result.CeraSpentNote,
}
s.recordCeraSpend(session, fields)
}
// recordGoldConsignCeraSpend 记一条"金币寄售买入"的点券消费。
//
// 为什么单独一个函数而不是复用 recordAlignedCeraSpend:金币寄售走的是拍卖行
// 玩法模块(access_auction 的 op185/op334),**不经过 alignedcmd**,
// 所以 alignedcmd.Result.CeraSpent 永远是 0,那条路覆盖不到这里。
//
// 字段口径与商城购买对齐(result / source / operation / payment_* / cera_*),
// 运营在「记录查询 → 点券消费记录」里能直接把两类消费放在一起看:
//
// · source="gold_consign" 用来区分来源(商城成功那条没有 source;
// aligned 那条是 "aligned_command");
// · payment_mode 复用商城的"点券"档(0),让 payment_mode 在所有点券记录里同值可比。
//
// 调用方必须保证 charged=true(真的扣了钱)才调用本函数 —— 重放同一个 op185 时
// 钱包不会再被扣一次,重复记账会让运营看到两条一模一样的消费。
func (s *Service) recordGoldConsignCeraSpend(
session *gameSession,
accountID, buyerID string,
listing dnfauction.GoldConsignListing,
ceraBefore, ceraAfter int64,
) {
if s == nil {
return
}
fields := map[string]any{
"result": "ok",
"source": "gold_consign",
"operation": "buy_gold_consign",
"payment_mode": currentCeraShopPaymentModeCera,
"payment_name": "点券",
// 实扣点券 = 寄售单标价。金币寄售没有"券抵扣",标价就是实扣。
"cera_spent": listing.PriceCera,
"cera_before": ceraBefore,
"cera_after": ceraAfter,
// 概要列直接显示这句,运营一眼能看出买的是哪一档金币。
"cera_spent_note": "金币寄售:" + goldConsignAmountText(listing.GoldAmount) + "金币",
// 显式补会话字段:activitySessionFields 只读 session.accountID /
// selectedCharacterID,而这里用的是 accountIDForSession 解析出来的值,
// 两者不一定同源(账号没绑定时前者可能就是空的)。记录里必须有人。
"account_id": strings.TrimSpace(accountID),
"character_id": strings.TrimSpace(buyerID),
// 寄售单与卖家侧信息(卖家侧的收入是代币券,不进本类别)。
"listing_id": listing.ID,
"gold_item_id": listing.GoldItemID,
"gold_amount": listing.GoldAmount,
"seller_id": listing.SellerID,
"seller_name": listing.SellerName,
"fee_percent": listing.FeePercent,
"proceeds_cera": listing.ProceedsCera,
}
s.recordCeraSpend(session, fields)
}
// goldConsignAmountText 把金币数量渲染成人读的形式(1_000_000 → "100万")。
//
// 金币档位最大到 2 亿,直接写原始数字在概要列里很难一眼分辨量级。
func goldConsignAmountText(amount int64) string {
switch {
case amount <= 0:
return "0"
case amount%100_000_000 == 0:
return strconv.FormatInt(amount/100_000_000, 10) + "亿"
case amount%10_000 == 0:
return strconv.FormatInt(amount/10_000, 10) + "万"
default:
return strconv.FormatInt(amount, 10)
}
}
// ================= 金币寄售记录 =================
// recordGoldConsign 记一条金币寄售事件(上架 / 下架 / 买家买入 / 成交收款 / 流拍退回)。
//
// 形状与 recordDungeon 一致:先铺会话身份字段,再写 event / event_name,
// 最后由调用方补齐本事件自己的字段。result 缺省为 "ok",只有"明确被拒绝"的
// 那几条才显式传 rejected + reason —— 记录里不出现"没有 result"的行:
// 少一个字段的拒绝行与成功行长得一模一样,事后没人分得清。
//
// 与「点券消费记录」的分工:那边记的是**钱**(买家实扣点券,字段口径与商城
// 购买对齐,方便两类消费同表对比),这里记的是**一单寄售的完整生命周期**
// (谁挂的、卖给了谁、手续费抽了多少、卖家实得多少、有没有真的投递出去)。
// 同一笔买入因此在两个类别里各有一行,这不是重复记账,而是两个检索口径。
func (s *Service) recordGoldConsign(session *gameSession, event string, fields map[string]any) {
if s == nil {
return
}
merged := s.activitySessionFields(session)
merged["event"] = event
merged["event_name"] = goldConsignEventName(event)
merged["result"] = "ok"
for key, value := range fields {
merged[key] = value
}
s.recordActivity(recordKindGoldConsign, merged)
}
// goldConsignEventName 金币寄售事件的中文名。
func goldConsignEventName(event string) string {
switch event {
case "list":
return "上架"
case "cancel":
return "下架"
case "buy":
return "买家买入"
case "sale_delivered":
return "成交收款"
case "expired":
return "流拍退回"
default:
return event
}
}
// ================= 拍卖行记录 =================
// recordAuction 记一条拍卖行事件
// (上架 / 下架 / 竞拍出价 / 一口价成交 / 竞拍成交 / 卖家领取收益)。
//
// 与 recordGoldConsign 同形,理由同上:拍卖行与金币寄售共用 op183/184/185,
// 但一个记的是物品竞拍(押金/竞价/一口价/手续费金币),另一个记的是金币档位
// (点券标价/代币券结算),字段集合不重叠,所以分成两个文件、两个类别。
func (s *Service) recordAuction(session *gameSession, event string, fields map[string]any) {
if s == nil {
return
}
merged := s.activitySessionFields(session)
merged["event"] = event
merged["event_name"] = auctionEventName(event)
merged["result"] = "ok"
for key, value := range fields {
merged[key] = value
}
s.recordActivity(recordKindAuction, merged)
}
// auctionEventName 拍卖行事件的中文名。
func auctionEventName(event string) string {
switch event {
case "list":
return "上架"
case "cancel":
return "下架"
case "bid":
return "竞拍出价"
case "buyout":
return "一口价成交"
case "won":
return "竞拍成交"
case "payout":
return "卖家领取收益"
default:
return event
}
}
// ================= 场景加载诊断记录 =================
// recordScene 记一条场景加载/引导包(scene bootstrap tail)诊断事件。
//
// 与 recordGoldConsign / recordAuction 同形:先铺会话身份字段,再写
// event / event_name,最后由调用方补齐本事件自己的字段。
//
// 为什么要有这个包装、而不是继续用 recordRaidEvent:这批诊断事件
// (scene_tail_deferred 一族)与团本毫无关系,却被写进了 raid 类别,于是
// 1. 只要有人在选人选场景/进图,每秒就冒出好几条,真正的团本事件被淹得翻不到;
// 2. 记录器每类只保留最近 recordRetentionPerKind 个文件,这批高频写入会飞快
// 把 raid-*.jsonl 顶到上限,把真正的团本记录连旧文件一起挤出保留窗口。
//
// 所以它们必须有自己的类别(recordKindScene),而不能复用团本的写入路径。
//
// 关于会话字段:这里额外补 conn_id / character_generation / session_online 三项,
// 与 recordRaidEvent 保持一致——搬家前这些字段就在 scene_tail_deferred 记录里,
// 少了它们等于排障时丢信息(尤其 session_online 是判断"玩家还在不在线"的第一手依据)。
func (s *Service) recordScene(session *gameSession, event string, fields map[string]any) {
if s == nil {
return
}
merged := s.activitySessionFields(session)
if session != nil {
merged["conn_id"] = session.connID
merged["character_generation"] = session.characterGeneration
merged["session_online"] = session.conn != nil
}
merged["event"] = event
merged["event_name"] = sceneEventName(event)
merged["result"] = "ok"
for key, value := range fields {
merged[key] = value
}
s.recordActivity(recordKindScene, merged)
}
// sceneEventName 场景加载诊断事件的中文名。
//
// 名字与 raidEventName 里的同名分支刻意保持一致:同一个事件只是换了文件,
// 概要列显示的文字不该跟着变,否则运营会以为出现了新事件。
func sceneEventName(event string) string {
switch event {
case "scene_tail_deferred":
// "被推迟"与"最终发出"共用同一个事件名,靠 result(deferred / sent)区分,
// 这个口径搬家前就是这样,保持不变。
return "场景尾巴包被推迟"
default:
return event
}
}
// activityRejectionFields 拒绝记录的公共字段:result=rejected + reason。
//
// 统一一处是为了让"被拒绝"在全部记录类别里永远是同一对键值:运营按 result
// 过滤就能捞出所有失败尝试,reason 再回答是哪一种拒绝(余额不足 / 价格不符 /
// 已被买走……)。调用方可以继续往返回的 map 里补字段。
func activityRejectionFields(reason string) map[string]any {
return map[string]any{"result": "rejected", "reason": reason}
}
// activityIdentityOverride 显式覆盖记录里的账号/角色标识,且一律写成字符串。
//
// 两条都是硬要求:
// 1. **显式**:activitySessionFields 读的是 session.accountID / selectedCharacterID,
// 而拍卖行这几条路径用的是 accountIDForSession / selectedCharacterIDString
// 解析出来的值,两者不一定同源(账号没绑定时前者可能就是空的);
// 2. **字符串**:运维工具只按字符串型读 account_id / character_id
// (handlers_records.go 的 recordAccountIDs),数字型会被静默忽略,
// 账号名翻译随即失效 —— 记录里就只剩一个没有主人的数字。
func activityIdentityOverride(fields map[string]any, accountID, characterID string) {
if accountID = strings.TrimSpace(accountID); accountID != "" {
fields["account_id"] = accountID
}
if characterID = strings.TrimSpace(characterID); characterID != "" {
fields["character_id"] = characterID
}
}
// ================= 副本记录 =================
// recordDungeon 记一条副本事件(enter / clear / exit)。
func (s *Service) recordDungeon(session *gameSession, event string, fields map[string]any) {
if s == nil {
return
}
merged := s.activitySessionFields(session)
merged["event"] = event
merged["event_name"] = dungeonEventName(event)
for key, value := range fields {
merged[key] = value
}
s.recordActivity(recordKindDungeon, merged)
}
// dungeonEventName 副本事件的中文名。
func dungeonEventName(event string) string {
switch event {
case "enter":
return "进入副本"
case "clear":
return "通关"
case "exit":
return "离开副本"
default:
return event
}
}
// dungeonRuntimeFields 组装一条副本记录里"这次刷的是哪个本、什么状态"的公共字段。
//
// 调用时机分三种(进入/通关/离开),三者能拿到的字段不完全一样,所以缺的就留空,
// 不为了凑字段去补一次仓储查询——副本埋点位于玩法主路径上,多查一次库就是给
// 玩家多一次卡顿。
func dungeonRuntimeFields(runtime *runtimeDungeonState) map[string]any {
fields := map[string]any{}
if runtime == nil {
return fields
}
if runtime.Dungeon.ID != 0 {
fields["dungeon_id"] = runtime.Dungeon.ID
}
if name := strings.TrimSpace(runtime.Dungeon.Metadata.Name); name != "" {
fields["dungeon_name"] = name
}
if path := strings.TrimSpace(runtime.Dungeon.Path); path != "" {
fields["dungeon_path"] = path
}
if runtime.clientDungeonID != 0 && runtime.clientDungeonID != runtime.Dungeon.ID {
fields["client_dungeon_id"] = runtime.clientDungeonID
}
fields["maze_index"] = runtime.MazeIndex
fields["difficulty"] = int(runtime.Request.Difficulty)
fields["request_dungeon_id"] = runtime.Request.DungeonID
fields["selection_mode"] = runtime.Request.SelectionMode
fields["entry_option"] = runtime.Request.EntryOption
fields["party_state"] = runtime.Request.PartyState
if runtime.lifecycleToken != 0 {
fields["lifecycle_token"] = runtime.lifecycleToken
}
if runtime.Character.CharacterID != "" {
if level := runtime.Character.Level; level != 0 {
fields["character_level"] = level
}
}
fields["practice"] = currentDungeonRuntimeIsPractice(runtime)
fields["tutorial"] = isPVFTutorialDungeon(runtime)
if !runtime.startedAt.IsZero() {
fields["started_at"] = runtime.startedAt.UTC().Format(time.RFC3339)
fields["elapsed_seconds"] = int64(time.Since(runtime.startedAt).Seconds())
}
if runtime.clearKillNormal != 0 || runtime.clearKillChampion != 0 || runtime.clearKillBoss != 0 {
fields["killed_normal"] = runtime.clearKillNormal
fields["killed_champion"] = runtime.clearKillChampion
fields["killed_boss"] = runtime.clearKillBoss
}
// 技能消耗的无色小晶块(判断外挂的关键指标之一)。
// 只在真发生过时输出,避免每条进入记录都挂一个 0;晶体契约的自动扣减不计入。
if runtime.colorlessSpent != 0 {
fields["colorless_spent"] = runtime.colorlessSpent
}
return fields
}
// recordDungeonEnter 记一条"进入副本"。
func (s *Service) recordDungeonEnter(session *gameSession, runtime *runtimeDungeonState) {
if s == nil || runtime == nil {
return
}
s.recordDungeon(session, "enter", dungeonRuntimeFields(runtime))
}
// recordDungeonClear 记一条"通关"。
//
// 只记一次由调用方保证:函数内部不持任何去重状态,落到运行时字段上的
// "结算前置屏障"(tutorialCompletionPersisted / clear-map 阶段 A)才是唯一判据。
func (s *Service) recordDungeonClear(session *gameSession, runtime *runtimeDungeonState, source string) {
if s == nil || runtime == nil {
return
}
fields := dungeonRuntimeFields(runtime)
fields["clear_source"] = source
if runtime.Session != nil {
snapshot := runtime.Session.Snapshot()
fields["run_status"] = string(snapshot.Run.Status)
fields["room"] = snapshot.Run.Current.String()
if snapshot.Scene.Map.Map.ID != 0 {
fields["map_id"] = snapshot.Scene.Map.Map.ID
}
fields["defeated_actor_count"] = len(snapshot.Scene.DefeatedObjects)
}
if runtime.settlementMonsterExperienceTotal != 0 {
fields["monster_experience_total"] = runtime.settlementMonsterExperienceTotal
}
s.recordDungeon(session, "clear", fields)
}
// recordCompletedReturnOutcome writes the outcome of a completed-dungeon 返回城镇
// into the GM dungeon record file (runtime/logs/records/dungeon-*.jsonl).
//
// It deliberately avoids the two obvious channels:
//
// - logGameEvent goes to packet_log.txt, which DROPS LINES under load. Measured
// on the live file 2026-09-25T07:00Z for conn game-003214: 99 of 855 adjacent
// pkt_seq pairs were discontinuous, i.e. ~25% of that connection's lines never
// landed. Five live 返回城镇 hangs (2026-09-24T17:07:49Z, 2026-09-25T05:25:22Z,
// 05:25:23Z, 06:55:10Z, 07:10:01Z) were unattributable for exactly that reason:
// every guard on the completed-return path is either silent or packet-log-only,
// so "the marker is absent" proved nothing.
// - logWarn goes to the slog logger, but on this deployment the server is started
// by the one-click launcher and its stdout is not captured at all
// (runtime/logs contains no server-*.stdout.log), so a warning there is
// invisible.
//
// The activity recorder's queue blocks rather than drops in normal operation
// (activity_record.go: the second select waits for room and only gives up while the
// recorder is shutting down), so this is the one channel that survives both.
func (s *Service) recordCompletedReturnOutcome(session *gameSession, outcome string, fields ...any) {
if s == nil || session == nil {
return
}
merged := map[string]any{"outcome": outcome}
for index := 0; index+1 < len(fields); index += 2 {
key, ok := fields[index].(string)
if !ok {
continue
}
merged[key] = fields[index+1]
}
s.recordDungeon(session, "return-outcome", merged)
}
// recordDungeonExit 记一条"离开副本"。
//
// reason 是运行时自己的生命周期结束原因(返回城镇、死亡回城、被新一次选本替换……),
// 排障时它比"玩家走了"有用得多。
func (s *Service) recordDungeonExit(session *gameSession, runtime *runtimeDungeonState, reason string) {
if s == nil || runtime == nil {
return
}
fields := dungeonRuntimeFields(runtime)
fields["exit_reason"] = strings.TrimSpace(reason)
fields["cleared"] = runtime.ordinaryFinalRoomClearAccepted || runtime.tutorialFinalRoomClearAccepted ||
runtime.bossDieCheckAccepted || runtime.settlementEntrySent
fields["settlement_entry_sent"] = runtime.settlementEntrySent
fields["settlement_result_sent"] = runtime.settlementResultNoticeSent
fields["town_return_sent"] = runtime.townReturnOp24Sent
s.recordDungeon(session, "exit", fields)
}
// ================= GM 查询接口 =================
// GMRecordQuery 记录查询条件(GM 接口入参)。
//
// Offset 与 Limit 一起构成分页:Limit 是单次返回上限(上限 2000,一次请求不可能把
// 全部记录吐出来),Offset 是跳过最新的多少条命中。要"看全部"就翻页,直到
// 返回的 Limited=false。
type GMRecordQuery struct {
Kind string
Limit int
Offset int
Keyword string
CharacterID string
CharacterName string
AccountID string
Since time.Time
Until time.Time
}
// GMRecordPage 记录查询结果。
type GMRecordPage struct {
Kind string `json:"kind"`
Title string `json:"title"`
Dir string `json:"dir"`
Records []map[string]any `json:"records"`
Count int `json:"count"`
Scanned int `json:"scanned"`
// Offset 是本次返回的起始位置(跳过的最新命中条数),调用方翻页时回填。
Offset int `json:"offset"`
// Limited=true 表示"还有更多记录"(本次是装满一页返回的),可以继续用
// Offset+Count 作为下一页起点再查。
Limited bool `json:"limited"`
Retention int `json:"retention_files"`
}
// GMRecordKindInfo 一个类别的元信息。
type GMRecordKindInfo struct {
Kind string `json:"kind"`
Title string `json:"title"`
}
// GMRecordMeta 记录系统元信息(目录、类别、文件列表)。
type GMRecordMeta struct {
Enabled bool `json:"enabled"`
Dir string `json:"dir"`
Kinds []GMRecordKindInfo `json:"kinds"`
Files []recordFileInfo `json:"files"`
}
// GMRecordMeta 返回记录目录与文件清单,供管理端展示"记录落在哪、有多大"。
func (s *Service) GMRecordMeta() GMRecordMeta {
meta := GMRecordMeta{Enabled: false, Kinds: make([]GMRecordKindInfo, 0, len(recordKinds))}
for _, kind := range recordKinds {
meta.Kinds = append(meta.Kinds, GMRecordKindInfo{Kind: kind, Title: recordKindTitles[kind]})
}
recorder := s.records.Load()
if recorder == nil {
meta.Dir = s.options.recordDir
return meta
}
meta.Enabled = true
meta.Dir = recorder.Dir()
meta.Files = recorder.listRecordFiles()
return meta
}
// GMQueryRecords 检索记录。kind 必须是三类之一,否则返回 ErrGMInvalidRequest。
func (s *Service) GMQueryRecords(query GMRecordQuery) (GMRecordPage, error) {
query.Kind = strings.ToLower(strings.TrimSpace(query.Kind))
if !isRecordKind(query.Kind) {
return GMRecordPage{}, fmt.Errorf("%w: 未知的记录类别 %q(可选:%s)",
ErrGMInvalidRequest, query.Kind, strings.Join(recordKinds, " / "))
}
page := GMRecordPage{
Kind: query.Kind,
Title: recordKindTitles[query.Kind],
Retention: recordRetentionPerKind,
}
recorder := s.records.Load()
if recorder == nil {
// 记录器没起来(目录不可写/服务未启动)不是错误:返回空页,让调用方看到 dir 提示。
page.Dir = s.options.recordDir
page.Records = []map[string]any{}
return page, nil
}
page.Dir = recorder.Dir()
limit := query.Limit
if limit <= 0 {
limit = 200
}
offset := query.Offset
if offset < 0 {
offset = 0
}
records, scanned, err := recorder.queryRecords(recordQuery{
Kind: query.Kind,
Limit: limit,
Offset: offset,
Since: query.Since,
Until: query.Until,
CharacterID: strings.TrimSpace(query.CharacterID),
CharacterName: strings.TrimSpace(query.CharacterName),
AccountID: strings.TrimSpace(query.AccountID),
Keyword: strings.TrimSpace(query.Keyword),
})
if err != nil {
return GMRecordPage{}, err
}
page.Records = records
page.Count = len(records)
page.Scanned = scanned
page.Offset = offset
// limited = "因为装满一页而停",用来告诉调用方还有更多(可以带着新的 offset 继续翻页,
// 或者缩小时间范围再查)。
page.Limited = len(records) >= limit
return page, nil
}