diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b5ddc0b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Docker 构建上下文忽略项(上下文为仓库根目录,用于 deploy/Dockerfile.build 的容器内编译) +.git +.gitignore +**/bin +**/obj +deploy/publish +deploy/dist +deploy/logs +deploy/uploads +*.tar diff --git a/.gitignore b/.gitignore index a5143c7..95445b1 100644 --- a/.gitignore +++ b/.gitignore @@ -365,4 +365,5 @@ FodyWeavers.xsd # ===== 部署产物(deploy/deploy.sh 运行时生成,不入库)===== deploy/publish/ deploy/logs/ -deploy/uploads/ \ No newline at end of file +deploy/uploads/ +deploy/dist/ \ No newline at end of file diff --git a/AdminSrc/AdminHost/AdminHost.csproj b/AdminSrc/AdminHost/AdminHost.csproj index 523824b..48dabcc 100644 --- a/AdminSrc/AdminHost/AdminHost.csproj +++ b/AdminSrc/AdminHost/AdminHost.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -18,6 +18,7 @@ + diff --git a/AdminSrc/AdminHost/Program.cs b/AdminSrc/AdminHost/Program.cs index c85bbe3..850b51e 100644 --- a/AdminSrc/AdminHost/Program.cs +++ b/AdminSrc/AdminHost/Program.cs @@ -98,6 +98,24 @@ builder.Services.AddCronJob(c => c.CronExpression = @"0 0 0 * * *"; }); +#region Swagger +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "AdminApi", + Description = "游戏后台管理系统", + Version = "v1" + }); + // 加载各项目生成的 XML 注释,用于展示接口的中文说明 + foreach (var xml in System.IO.Directory.GetFiles(AppContext.BaseDirectory, "*.xml")) + { + try { c.IncludeXmlComments(xml, true); } catch { } + } +}); +#endregion + builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); builder.Host.ConfigureContainer(builder => { @@ -113,6 +131,15 @@ if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); } +#region Swagger +app.UseSwagger(c => c.RouteTemplate = "adminswagger/{documentName}/swagger.json"); +app.UseSwaggerUI(c => +{ + c.RoutePrefix = "adminswagger"; + c.SwaggerEndpoint("v1/swagger.json", "AdminApi v1"); +}); +#endregion + app.UseCors("Cors"); app.UseRouting(); app.UseStaticFiles(); diff --git a/AdminSrc/AdminHost/wwwroot/admin/socket.html b/AdminSrc/AdminHost/wwwroot/admin/socket.html new file mode 100644 index 0000000..4f52397 --- /dev/null +++ b/AdminSrc/AdminHost/wwwroot/admin/socket.html @@ -0,0 +1,624 @@ + + + + + +SignalR WebSocket 协议文档 + + + +
+ + +
+
+

SignalR WebSocket 协议文档

+

所有 Hub 支持 WebSocket 传输。LoginHub 和 CustomerHub 客服端需 JWT Token(access_token 参数)。本轮不调整现有时间字段的单位和解析方式。

+
+ + +
+
+
+
LoginHub — /loginhub
+
WS + JWT,用于大厅资产同步、全服广播、邮件通知
+
+ +

📤 客户端 → 服务器

+ + + + +
方法参数说明
C→S Bindstring userId必须:连接后立即调用,将连接与 userId 绑定。服务端加入 userId 命名的 SignalR 组,后续定向推送通过此组发送。
C→S DismissPendingRoomNEWstring userId用户拒绝返回断线房间。登录后若收到 PendingRoomNotice,用户选择"离开"时调用此方法,服务端清理旧房间残留。
+ +

📥 服务器 → 客户端

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
事件数据发送时机
S→C Broadcast
{
+  UserName: string,   // 昵称
+  AvatarUrl: string,  // 头像URL
+  Level: int,         // 等级
+  VipLevel: int,      // VIP等级
+  Message: string,    // 消息内容
+  Time: long          // Unix时间戳(秒)
+}
大喇叭全服广播
S→C KillAnnouncement
{
+  Text: string,          // 公告文案: "恭喜玩家momo在风雷之渊高级场捕获 金龙*2000炮倍 100000000大奖"
+  UserName: string,      // 玩家名: "momo"
+  GameTypeName: string,  // 游戏类型: "风雷之渊"
+  RoomName: string,      // 房间名: "高级场"
+  FishName: string,      // 鱼名: "金龙"
+  Multiple: int,         // 倍数: 2000
+  Gold: long,            // 获得金币: 100000000
+  Time: long             // Unix时间戳(秒)
+}
全局击杀公告(特殊Boss击杀 / 机器人模拟击杀),走 LoginHub
S→C AssetUpdated
{
+  CurrencyType: int,  // 货币类型: 1=金币 2=保险箱 3=钻石
+  ChangeType: int,    // 变动类型: 1=增加 2=减少
+  Amount: long,       // 变动数量
+  NewGold: long,      // 最新金币
+  NewDiamond: int,    // 最新钻石
+  Source: string,     // 来源: "游戏金币结算"、"救济金"、"签到" 等
+  Remark: string      // 备注(房间名等)
+}
资产变更:退出房间结算 / 后台充值扣币 / 救济金 / 签到 / 捕鱼返利
S→C LevelChangedNEW
{
+  NewLevel: int,      // 最新等级
+  NewExp: long,       // 最新经验
+  NewGold: long,      // 最新金币
+  NewDiamond: int,    // 最新钻石
+  Source: string,     // 来源
+  Remark: string      // 备注
+}
后台修改玩家等级
S→C BackpackUpdated
{
+  Action: string,     // "add" | "update" | "delete"
+  PropNumber: string, // 道具编号
+  Count: int          // 数量
+}
道具背包变更(后台操作)
S→C NewEmail
{
+  Title: string,   // 邮件标题
+  EmailId: string  // 邮件ID
+}
收到新邮件通知
S→C ChangeConfigNEW
changeType: int
+
+1 = 活动配置更新
+2 = 游戏配置更新
后台活动配置或游戏配置保存成功并发布 CAP 后,由 UserHost 通过 LoginHub 向所有在线玩家广播。前端收到后应按 changeType 重新请求对应配置。监听示例:connection.on("ChangeConfig", changeType => { ... })
S→C AccountKickedNEW
{
+  userId: string   // 被顶的玩家ID
+}
账号被顶通知。该账号在其他设备登录时,旧连接收到此事件。前端应提示"账号已在其他地方登录"并清空房间 UI、断开游戏连接。
S→C KickToMainPageNEW
{
+  userId: string   // 被踢的玩家ID
+}
强制退出到主页面。当前账号的连接已失效(被顶号/用户不匹配),前端收到后应立即清除所有游戏 UI 并导航到主页面/登录页。
S→C PendingRoomNoticeNEW
{
+  roomId: string,    // 房间ID
+  gameType: string,  // 游戏类型: "风雷之渊"
+  roomName: string   // 房间名: "高级场"
+}
登录后检测到有未完成的断线游戏。金币已结算至当前余额。前端应弹窗提示用户选择"返回房间"(调 Reconnect)或"离开"(调 DismissPendingRoom),60秒无操作自动离开。
+
+
+ + +
+
+
+
FishGameHub — /fishinghub
+
WS,捕鱼游戏核心 Hub。连接后先调 Reconnect 绑定用户。
+
+ +

🤖 机器人说明:捕鱼房间中的机器人(IsBot=true)由服务端完全模拟。客户端无需发送机器人指令,机器人在协议层面与真人一致——同样触发 RoomFireNotice、RoomKillNotice 等事件。

+ +

📤 客户端 → 服务器

+ + + + + + + + + + + + + + +
方法参数返回说明
C→S Reconnectstring userId无断线重连必须先调用。服务端返回 RoomInfo(含 activeFishes)并恢复房间状态
C→S JoinRoomstring roomId, string userId, int posituin无加入房间。posituin=-1 自动分配座位
C→S FireCannonstring roomId, string userId, long gold, int count, long timestamp, string sign, object? extra
{ bullets: [{ bulletId: string, gold: long }], gold: long, timestamp: long }
发炮。每颗返回子弹携带本次发射炮倍;外层 gold 为玩家剩余金币。sign=MD5签名
C→S KillFishstring roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign
{ gold: long, timestamp: long }
击杀鱼
C→S LeaveRoomstring roomId, string userId无退出房间(金币结算后自动 Context.Abort 断开)
C→S UsePropstring roomId, string userId, string propNumber, int diamond=0int 剩余数量使用道具(钻石购买时传 diamond)。返回背包剩余数量,-1 表示失败。冰冻道具(40003)触发 FishFrozen 事件
C→S ChangeGunMultiplestring roomId, string userId, int multiple无更改炮倍
C→S GetRoomInfostring roomId无结果通过 GetRoomInfoResult 返回
C→S GetPlayerInfostring roomId, string userId无获取玩家信息
C→S SyncGameDatastring roomId, object data无同步游戏数据
C→S SetPlayerAfkNEWstring roomId, string userId无玩家挂机,触发 PlayerAfkNotice
C→S CancelPlayerAfkNEWstring roomId, string userId无取消挂机,触发 PlayerCancelAfkNotice,返回 RoomInfo(含 activeFishes)同步当前鱼状态
+ +

📥 服务器 → 客户端

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
事件数据目标说明
S→C BindSuccessstring调用者重连绑定成功
S→C Exception
{
+  code: int,      // 错误码(0=未知错误)
+  message: string  // 错误描述
+}

错误码说明:
1001=房间已满
1002=金币不足
1003=房间不存在
1004=玩家不在房间
1005=签名错误
1006=请求已过期
1007=鱼配置不存在
1008=子弹参数无效
1009=金币参数超出范围
1011=用户不存在
1012=道具配置不存在
1013=道具数量不足
1014=钻石不足
1015=炮倍参数无效
调用者错误通知。前端根据 code 处理业务逻辑
S→C RoomInfo
RedisGameRoom 扩展字段,包含首次及当前活跃鱼:
+{
+  RoomId, RoomName, GameType, RoomLevel, RoomPartition,
+  RoomNumber, MaxPlayerCount, CurrentOnlinePlayerCount,
+  RobotCount, Players[], CreateUserId, SystemFactor,
+  activeFishes: [{            // 首次进房/后续进房/重连/取消挂机时包含
+    fishId: number,
+    fishIds: number[],
+    fishNumber: string,
+    routeId: number,
+    fishArrayNumber: string,
+    createdAt: number,         // 毫秒
+    expireAt: number,          // 毫秒
+    remainingSeconds: int
+  }]
+}
调用者加入房间/断线重连/取消挂机返回房间信息。先用 activeFishes 初始化鱼群;服务端约 300ms 后还会向当前玩家补发同一批 OnFishSpawn(init),前端须按 fishId 去重。
S→C SyncPlayerGoldlong调用者金币同步(金币≤0时触发)
S→C SendPlayerInfoResultlong?调用者玩家Gold值
S→C GetRoomInfoResultRedisGameRoom?调用者房间信息查询结果
S→C RoomKillNotice
{
+  fishNumber: string, userId: string, gold: long,
+  fishId: string, timespan: long, killtime: long,
+  msg: string,         // "时间:X秒"
+  beishu: double,
+  skillsGold: long,    // 炮倍金币
+  fishScore: int,
+  param: object?,      // Boss爆奖结果
+  drop: object?        // Boss掉落 { propNumber, propName, count }
+}
房间组击杀通知
S→C RoomFireNotice{ userId, bulletIds[], gold, count, extra, isBot }房间组发炮通知
S→C RoomJoinNotice
{
+  userId: string,           // 玩家ID
+  userName: string,         // 玩家名字
+  userNumber: string,       // 玩家编号
+  avatar: string,           // 玩家头像
+  isRedName: bool,          // 是否红名
+  level: int,               // 玩家等级
+  exp: long,                // 经验值
+  maxExp: long,             // 当前等级最大经验值
+  vipLevel: int,            // VIP等级
+  gold: long,               // 金币
+  diamond: int,             // 钻石
+  cannonId: string,         // 炮台
+  wingsId: string,          // 翅膀
+  warGodId: string,         // 战神
+  cannonMultiple: int,      // 炮倍
+  position: int,            // 位置
+  beginCoin: long,          // 进场金币
+  maxGold: long,            // 最高金币
+  beginTime: long,          // 进场时间
+  state: int,               // 状态 0正常 1断线中
+  playerFactor: object,     // 玩家因子
+  totalRechargeGold: long,  // 累计充值
+  safeBoxGold: long,        // 保险箱金币
+  isBot: bool,              // 是否机器人
+  isAfk: bool               // 是否挂机
+}
房间组玩家加入
S→C RoomLeaveNoticestring userId房间组玩家离开
S→C BotLeaveRoomNoticeNEW
{ userId: string }
房间组机器人退出房间
S→C HostDisconnectNEW无房间组房主断线,其他玩家可接受机器人控制权
S→C RoomDisconnectNoticestring userId房间组玩家断线
S→C ChangeGunMultipleNotice{ userId, multiple }房间组炮倍变更
S→C ChangeControllerUserstring userId房间组房主控制权移交
S→C UsePropNotice{ userId, propNumber }房间组使用道具
S→C SummonCardFishNoticeNEW
{
+  roomId: string,       // 房间ID
+  userId: string,       // 使用召唤卡的玩家ID
+  userNumber: string,   // 玩家编号
+  userName: string,     // 玩家昵称
+  fishType: int,        // 2=Boss鱼,3=黄金鱼
+  fishTypeName: string, // "Boss鱼"或"黄金鱼"
+  fishNumber: string,   // 召唤出的鱼配置编号
+  fishId: number        // 本次生成的鱼实例ID
+}
房间组召唤卡成功生成鱼后广播。鱼的路径和生命周期仍由 OnFishSpawn 处理;本事件用于显示“哪个玩家召唤了什么鱼”等提示。
S→C PlayerAfkNoticeNEW{ userId: string }房间组玩家挂机通知
S→C PlayerCancelAfkNoticeNEW{ userId: string }房间组取消挂机通知
S→C TorpedoResultNoticeNEW
{ userId: string, propNumber: string, gold: long, color: string }
房间组鱼雷使用结果通知
S→C OnPlayerInfoNEW
{ userId, userName, gold, diamond, viplvl, cannonMultiple, cannonId, wingsId, position }
调用者玩家信息同步
S→C SyncGameDataNEWobject data房间组游戏数据同步(服务端主动推送)
S→C FishFrozenNEW无房间组冰冻道具(40003)生效,鱼存活时间延长 15 秒(客户端写死 15s 倒计时)
S→C OnSeatChange
// type=join 时:
+{ type: "join", playerId: string, position: int, playerName: string,
+  nickname: string, gold: long, diamond: int, vipLevel: int,
+  turretId: string, turretRate: int, turretLv: int, headId: string,
+  roomRate: int, bulletPower: int }
+// type=leave 时:
+{ type: "leave", userId: string, playerId: string,
+  position: int, showWaiting: bool? }
房间组座位变更(加入/离开)
S→C OnFishSpawnNEW
{
+  type: "init" | "spawn" | "sync", // init=进房补发,spawn=定时出鱼,sync=主动同步
+  timestamp: number,        // 服务器当前时间戳(毫秒)
+  fish: [{
+    fishId: number,         // 普通鱼ID;鱼阵时为首条普通鱼ID(兼容旧客户端)
+    fishNumber: string,     // 普通鱼=鱼编号;鱼阵=""
+    routeId: number,        // 普通鱼=路径编号;鱼阵=固定阵型编号(如71001)
+    fishArrayNumber: string,// 鱼阵编号(非鱼阵时为"")
+    fishIds: number[],      // 所有出鱼必传;普通鱼=[fishId],鱼阵=阵内全部鱼ID
+    createdAt: number,      // 鱼创建时间戳(毫秒),前端用它算路径进度
+    expireAt: number,       // 鱼过期时间戳(毫秒)
+    remainingSeconds: int   // 鱼剩余存活秒数
+  }]
+}
房间组(init/spawn)/调用者(sync)fishIds 在首次出鱼、定时出鱼、入房及重连同步中都必须提供。普通鱼为单元素集合 [fishId];鱼阵按鱼阵配置的生成顺序使用 ID 集合。鱼阵的 fishNumber 为空,鱼信息由 fishArrayNumber 对应的本地配置确定。击中时把对应 ID 作为 KillFish 的 fihsId 原样传回。
S→C KillNotice
{
+  FishId: string,     // 鱼编号
+  userName: string,   // 玩家名
+  gold: long,         // 金币
+  // 机器人模拟时额外字段:
+  fishName: string?,  // 鱼名
+  multiplier: int?    // 倍数
+}
全部全局击杀公告(真人击杀/机器人模拟)
+
+
+ + +
+
+
+
FishRoomHub — /fishingroomhub
+
WS,房间列表
+
+ +

📤 客户端 → 服务器

+ + + + +
方法参数说明
C→S GetRoomListstring userId, int gameType, int roomLevel, int roomPartition获取房间列表。结果通过 GetRoomListResult 事件返回
C→S GetRoomInfostring roomId获取房间详情
+ +

📥 服务器 → 客户端

+ + + + + +
事件数据说明
S→C GetRoomListResultList<GameRoomReponse>
GameRoomReponse: { RoomId, MaxPlayerCount, CurrentOnlinePlayerCount, RobotCount, RoomNumber }
房间列表(仅统计真人)
S→C RoomChangeRedisGameRoom房间变化通知(人数、状态变更)
S→C GetRoomInfoResultRedisGameRoom?房间详情
+
+
+ + +
+
+
+
ChessGameHub — /chessgamehub
+
WS,中国象棋
+
+ +

🏠 房间列表说明:大厅只返回后台预配置的固定数字房间号。服务端不会再创建随机 GUID 展示房间,并会过滤、清理“无房主、无玩家”的旧异常房间。前端收到 GetRoomList、CreateRoomNotice、JoinRoomNotice 或 ExitRoomNotice 后,应按 roomId 更新同一条房间数据,不要追加重复房间。

+ +

📤 客户端 → 服务器

+ + + + + + + + + + + + + + + + + +
方法参数说明
C→S CreateRoomCreateRoomParam { roomId, userId, gameType(1=我方黑 2=我方红), password, gold }创建房间
C→S JoinGamestring gameId, string pwd, string userId加入游戏
C→S GameReadystring gameId, string userId准备
C→S EndGamestring gameId, string winUserId直接结束(对方获胜)
C→S RequestEndGamestring gameId, string userId, string winUserId请求结束(需双方确认)
C→S RestartGamestring gameId, string userId对局结束后重新开始
C→S GoldConfigstring gameId, long gold, string userId房主设置金币
C→S ExitRoomstring gameId, string userId退出(房主退出清空房间,非房主保留房间)
C→S Heartbeatstring gameId心跳,更新活跃时间
C→S CheckTimeoutstring gameId检查超时(60秒断线判负)
C→S GetRoomListint partition=0房间列表;0=全部分区,正数=指定分区
C→S GetRoomStatestring gameId获取当前房间完整状态,用于返回场景或重连后主动校准
C→S KickPlayerstring gameId, string targetUserId房主在开局前踢出指定玩家
C→S SyncGameDatastring gameId, string data同步棋子移动
C→S GameOperationstring gameId, int type, string data
type: 1=求和 2=认输(自动结束) 3=悔棋
游戏操作
+ +

📥 服务器 → 客户端

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
事件数据目标
S→C GameErrorstring调用者
S→C BeginGameChessGameRoom调用者/房间组
S→C JoinRoomChessGameRoom调用者
S→C JoinRoomNoticeChessGameRoom象棋大厅组
S→C RoomStateNoticeChessGameRoom房间组/调用者,房间完整状态
S→C GoldConfigRequired{ targetUserId, room }房间组,两名真人到齐后提示指定红方设置金币
S→C Reconnect{ userId, userName }房间组
S→C Disconnect{ userId, disconnectTime, timeoutSeconds }房间组
S→C ReadyNoticestring userId房间组
S→C AllBeginTimeChessGameRoom房间组
S→C GameEnd{ winUserId, room }房间组
S→C EndGameRequestSentstring调用者
S→C OpponentRequestEndstring userId房间组
S→C RestartGameSuccessChessGameRoom房间组
S→C RestartRoomNoticeChessGameRoom全部
S→C ExitRoomNoticeChessGameRoom象棋大厅组
S→C ExitRoomSuccessstring userId房间组
S→C KickedFromRoom{ gameId, userId }被踢玩家
S→C CreateRoomNoticeChessGameRoom象棋大厅组
S→C GoldConfigSuccessChessGameRoom房间组
S→C GoldConfigRoomNoticeChessGameRoom全部
S→C GetRoomListList<ChessGameRoom>调用者;仅固定房间,按 roomId 覆盖本地列表
S→C SyncGameDatastring全部
S→C GameOperation{ type, data }全部
+
+
+ + +
+
+
+
CustomerHub — /customerHub
+
WS,客服即时通讯。客服端需 JWT,玩家端无需 Token。
+
+ +

📤 客户端 → 服务器

+ + + + + + +
方法参数说明
C→S JoinConversationstring conversationId加入会话房间组
C→S LeaveConversationstring conversationId离开会话房间组
C→S SendMessagestring conversationId, int senderType, string senderId, string senderName, string? senderAvatar, int contentType, string content, string guId=""
senderType: 1=用户 2=客服
contentType: 1=文字 2=图片
conversationId 为空时自动创建新会话
发送消息
C→S StartConversationstring userId, string userName主动发起新会话
+ +

📥 服务器 → 客户端

+ + + + + + + +
事件数据目标
S→C OnMessageReceived
{
+  GuId: string,
+  ConversationId: string,
+  SenderType: int,
+  SenderId: string,
+  SenderName: string,
+  SenderAvatar: string?,
+  ContentType: int,
+  Content: string,
+  CreateTime: DateTime
+}
会话组
S→C OnConversationStartedConversationEntity调用者
S→C OnNewConversation{ conversationId, customerId?, customerName?, userId?, userName?, status, createTime }客服组
S→C Exception
{
+  code: int,
+  message: string
+}
调用者错误通知,code 同上表
+

注意:客服连接时携带 JWT,服务端自动从 uid Claim 加入通知组,无需手动 JoinCustomerQueue。

+
+
+ + +
+
+

🔗 Hub 路由总览

+ + + + + + + +
Hub路由认证
LoginHub/loginhubJWT
FishGameHub/fishinghub方法传参 userId
FishRoomHub/fishingroomhub方法传参 userId
ChessGameHub/chessgamehub方法传参 userId
CustomerHub/customerHub客服需JWT,玩家无需
+
+ +
+

📦 通用数据结构

+ + + + + + + + + + +
名称字段
RedisGameRoom
{
+  RoomId, RoomName, GameType, RoomLevel, RoomPartition,
+  RoomNumber, MaxPlayerCount, CurrentOnlinePlayerCount,
+  RobotCount, Players[], CreateUserId,
+  SystemFactor: double     // 当前系统因子(根据当日盈利动态调整)
+}
RedisGamePlayer
{
+  UserId, UserName, UserNumber, Avatar, IsRedName,
+  Level, VipLevel, Gold, Diamond, Position,
+  CannonId, WingsId, WarGodId, CannonMultiple,
+  BeginCoin, MaxGold, BeginTime, State(0=正常 1=断线),
+  CId, PlayerFactor, TotalRechargeGold, SafeBoxGold,
+  IsBot, IsAfk
+}
ChessGameRoom
{
+  RoomId, state(1=等待 2=游戏中 3=已结束), users[],
+  gameType(1=我方黑 2=我方红), gold, password,
+  createUserId, robotCount, isGameEnded,
+  timeoutSeconds(默认60), endGameRequests, pendingWinUserId
+}
ConversationEntity
{
+  UserId?, UserName?, CustomerId?, CustomerName?,
+  Status(0=等待 1=进行中 2=已结束), CreateTime,
+  EndTime?, LastReadTime?
+}
+
+ +
+

📤 发送基础设施(IHubSend<T>)

+ + + + + + + +
方法说明
SendUserCId(cid, method, obj)发送给指定连接
SendUserUserId(userId, method, obj)通过 userId 查找 CId 后发送
SendGroup(groupId, method, obj)发送给 SignalR 群组
SendAll(method, obj)广播给所有客户端
GroupExcept(cid, groupId, method, obj)发送给群组排除指定连接
+
+ +
+

流程说明

+ +

1. 登录 / Bind 流程 (LoginHub)

+
    +
  1. 客户端连接 /loginhub (JWT)
  2. +
  3. 调用 Bind(userId),加入 userId 组
  4. +
  5. Bind 读取 Redis LoginCId: +
      +
    • 若存在旧 LoginCId 且不等于当前连接 -> 顶号: +
        +
      • 发 AccountKicked 给旧 LoginHub 连接
      • +
      • 查旧 LoginHub 关联的游戏 Hub CId,发 KickToMainPage
      • +
      +
    • +
    • 更新 Redis LoginCId = 当前连接 CId
    • +
    +
  6. +
  7. 检测断线房间 (PendingRoomId): +
      +
    • 有 -> 发 PendingRoomNotice(弹窗:返回房间 / 离开)
    • +
    • 无 -> 进入大厅
    • +
    +
  8. +
+ +

2. 顶号流程

+
    +
  • 设备 A 登录:Bind -> LoginCId = cid_A
  • +
  • 设备 B 登录:Bind -> LoginCId = cid_A != cid_B -> 发 AccountKicked 到 cid_A -> 更新 LoginCId = cid_B -> A 被顶
  • +
  • 设备 C 登录:Bind -> LoginCId = cid_B != cid_C -> 发 AccountKicked 到 cid_B -> 更新 LoginCId = cid_C -> B 被顶
  • +
  • 关键:仅 LoginHub.Bind 负责发 AccountKicked,FishGameHub 不再发送
  • +
+ +

3. JoinRoom 流程 (FishGameHub)

+
    +
  1. 客户端调 JoinRoom(roomId, userId, posituin)
  2. +
  3. 检查玩家是否已在房间 -> 是则返回
  4. +
  5. 获取/创建房间 roomInfo
  6. +
  7. 判断是否首个真人进房: +
      +
    • 是 -> RegisterRoom(roomId, roomLevel) 注册出鱼服务
    • +
    • 否 -> GetActiveFishes(roomId) 获取当前活跃鱼列表
    • +
    +
  8. +
  9. 发 RoomInfo(cid, room, activeFishes) 给调用者
  10. +
  11. 发 OnSeatChange(join) 通知房间组
  12. +
+ +

4. Reconnect 流程 (FishGameHub)

+
    +
  1. 客户端连接 FishGameHub 后立即调 Reconnect(userId)
  2. +
  3. 查 Redis 有无断线房间 (PendingRoomId): +
      +
    • 有 -> 恢复玩家状态 State=0,走 JoinRoom 逻辑(含鱼同步)
    • +
    • 无 -> 无操作,等待后续指令
    • +
    +
  4. +
  5. 返回结果:RoomInfo(首个真人注册后的首次鱼群放入 activeFishes,约 300ms 后向当前玩家补发 OnFishSpawn(type=init))
  6. +
+ +

5. CancelPlayerAfk 流程

+
    +
  1. 客户端调 CancelPlayerAfk(roomId, userId)
  2. +
  3. 清除玩家 IsAfk 标记
  4. +
  5. GetActiveFishes(roomId) 获取当前活跃鱼
  6. +
  7. 发 RoomInfo(cid, room, activeFishes) 给调用者(同步鱼状态)
  8. +
  9. 发 PlayerCancelAfkNotice 通知房间组
  10. +
+ +

6. LeaveRoom / 退出流程

+
    +
  1. 客户端调 LeaveRoom(roomId, userId)
  2. +
  3. 金币结算,从房间移除玩家
  4. +
  5. 发 RoomLeaveNotice 通知房间组
  6. +
  7. 检查剩余真人: +
      +
    • 无真人 -> UnregisterRoom(roomId) 注销出鱼 + 清理房间
    • +
    • 有真人 -> 房主转移(若原房主退出),发 ChangeControllerUser
    • +
    +
  8. +
+ +

7. 房主断线 / 所有权转移

+
    +
  1. 房主断线触发 HandleDisconnect
  2. +
  3. 查找下一个非挂机真人作为新房主: +
      +
    • 找到 -> 转移所有权,发 ChangeControllerUser(新房主 userId) 通知房间组
    • +
    • 未找到 -> 发 HostDisconnect(前端禁用发炮/出鱼,显示等待提示)
    • +
    +
  4. +
+ +

8. 出鱼服务 (FishSpawnService)

+
    +
  • 首个真人进房 -> RegisterRoom -> 启动定时器持续出鱼
  • +
  • 定时出鱼 -> OnFishSpawn(type=spawn) 广播房间组
  • +
  • 后续真人进房/重连/取消挂机 -> GetActiveFishes -> 嵌入 RoomInfo.activeFishes 返回,并约 300ms 后补发 OnFishSpawn(type=init)
  • +
  • 最后一个真人退出/断线 -> UnregisterRoom -> 停止定时器 + 清除活跃鱼
  • +
+
+
+ +
+
+ + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..6665b01 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + + + true + $(NoWarn);1591 + + + diff --git a/FishGameSrc/FishGameHost/FishGameHost.csproj b/FishGameSrc/FishGameHost/FishGameHost.csproj index e96195e..88bbfa3 100644 --- a/FishGameSrc/FishGameHost/FishGameHost.csproj +++ b/FishGameSrc/FishGameHost/FishGameHost.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -17,6 +17,7 @@ +
diff --git a/FishGameSrc/FishGameHost/Program.cs b/FishGameSrc/FishGameHost/Program.cs index c7ff392..fbf8aec 100644 --- a/FishGameSrc/FishGameHost/Program.cs +++ b/FishGameSrc/FishGameHost/Program.cs @@ -138,6 +138,24 @@ builder.Services.AddRedisCacheSingleton(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("BulletConfig")); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); +#region Swagger +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo + { + Title = "FishGameHost", + Description = "捕鱼游戏接口", + Version = "v1" + }); + // 加载各项目生成的 XML 注释,用于展示接口的中文说明 + foreach (var xml in System.IO.Directory.GetFiles(AppContext.BaseDirectory, "*.xml")) + { + try { c.IncludeXmlComments(xml, true); } catch { } + } +}); +#endregion + builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); builder.Host.ConfigureContainer(builder => { @@ -165,6 +183,15 @@ if (app.Environment.IsDevelopment()) { } +#region Swagger +app.UseSwagger(c => c.RouteTemplate = "roomswagger/{documentName}/swagger.json"); +app.UseSwaggerUI(c => +{ + c.RoutePrefix = "roomswagger"; + c.SwaggerEndpoint("v1/swagger.json", "FishGameHost v1"); +}); +#endregion + app.UseApiLogging(); app.UseRouting(); app.UseGlobalExceptionHandler(); diff --git a/FishGameSrc/FishGameService/GameSend/FishingSendService.cs b/FishGameSrc/FishGameService/GameSend/FishingSendService.cs index 4e51a5d..1aa0b1f 100644 --- a/FishGameSrc/FishGameService/GameSend/FishingSendService.cs +++ b/FishGameSrc/FishGameService/GameSend/FishingSendService.cs @@ -175,12 +175,12 @@ namespace Service.Fishing roomId, cid, initialFishPayload.Count); } } - public void RoomKillNotice(string roomId, string fishNumber, string userId, long gold, string fishId, long timespan, double beishu = 1,long skillsGold=0,int fishScore=1, object param = null, object drop = null) + public void RoomKillNotice(string roomId, string fishNumber, string userId, long gold, string fishId, long timespan, double beishu = 1,long skillsGold=0,int fishScore=1, object param = null, object drop = null, long gainExp = 0, long exp = 0, int level = 0, long expMax = 0) { var killtime=DateTime.Now.ToTimeStamp(); - _hubSend.SendGroup(roomId, "RoomKillNotice", new { fishNumber, userId, gold, fishId, timespan, killtime, msg = $"时间:{killtime - timespan}秒", beishu, skillsGold, fishScore, param, drop }); + _hubSend.SendGroup(roomId, "RoomKillNotice", new { fishNumber, userId, gold, fishId, timespan, killtime, msg = $"时间:{killtime - timespan}秒", beishu, skillsGold, fishScore, param, drop, gainExp, exp, level, expMax }); } diff --git a/FishGameSrc/FishGameService/GameSend/IFishingSendService.cs b/FishGameSrc/FishGameService/GameSend/IFishingSendService.cs index f9cc977..a422bb5 100644 --- a/FishGameSrc/FishGameService/GameSend/IFishingSendService.cs +++ b/FishGameSrc/FishGameService/GameSend/IFishingSendService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -18,7 +18,7 @@ namespace FishGameService.GameSend void SyncGameData(string roomId, object data); - void RoomKillNotice(string roomId, string fishNumber, string userId, long gold, string fishId, long timespan, double beishu = 1,long skillsGold = 0,int fishScore = 1, object param = null, object drop = null); + void RoomKillNotice(string roomId, string fishNumber, string userId, long gold, string fishId, long timespan, double beishu = 1,long skillsGold = 0,int fishScore = 1, object param = null, object drop = null, long gainExp = 0, long exp = 0, int level = 0, long expMax = 0); void RoomJoinNotice(string roomId, RedisGamePlayer player); diff --git a/FishGameSrc/FishGameService/GameService/FishingGameService.cs b/FishGameSrc/FishGameService/GameService/FishingGameService.cs index a9e9726..a2ab675 100644 --- a/FishGameSrc/FishGameService/GameService/FishingGameService.cs +++ b/FishGameSrc/FishGameService/GameService/FishingGameService.cs @@ -1058,9 +1058,6 @@ namespace FishGameService.GameService } } - _sendResult.RoomKillNotice(roomId, fishNumber, player.UserId, skillGold, fihsId, timespan,res.beishu, gold,fishConfig.FishScore, res.res); - await FishKillControl(cid, gameRoom, player, fishNumber, skillGold, fihsId,gold,timespan, killConfig); - var expGain = fishConfig.FishType is FishTypeEnum.Boss鱼 or FishTypeEnum.特殊鱼 or FishTypeEnum.特殊Boss ? 1000 : fishConfig.FishScore; if (expGain > 0) @@ -1076,7 +1073,8 @@ namespace FishGameService.GameService Source = RewardSource.击杀鱼, Remark = "捕鱼经验", RoomInfo = roomId, - IsAddRecord = false + IsAddRecord = false, + SyncToFrontend = false }); expSaved = expResult.isok; } @@ -1097,6 +1095,9 @@ namespace FishGameService.GameService } } + _sendResult.RoomKillNotice(roomId, fishNumber, player.UserId, skillGold, fihsId, timespan,res.beishu, gold,fishConfig.FishScore, res.res, gainExp: killExpGain, exp: player.Exp, level: player.Level, expMax: player.MaxExp); + await FishKillControl(cid, gameRoom, player, fishNumber, skillGold, fihsId,gold,timespan, killConfig); + if ((fishConfig.FishType == FishTypeEnum.Boss鱼 || fishConfig.FishType == FishTypeEnum.特殊Boss || fishConfig.FishType == FishTypeEnum.特殊鱼) && fishNumber != "12028" && killConfig.BossDropConfig != null @@ -1131,7 +1132,7 @@ namespace FishGameService.GameService Source = RewardSource.击杀Boss, IsAddRecord = false }); - _sendResult.RoomKillNotice(roomId, fishNumber, player.UserId, skillGold, fihsId, timespan, res.beishu, gold, fishConfig.FishScore, drop: new { wonItem.PropNumber, wonItem.PropName, wonItem.Count }); + _sendResult.RoomKillNotice(roomId, fishNumber, player.UserId, skillGold, fihsId, timespan, res.beishu, gold, fishConfig.FishScore, drop: new { wonItem.PropNumber, wonItem.PropName, wonItem.Count }, exp: player.Exp, level: player.Level, expMax: player.MaxExp); } } @@ -1156,7 +1157,7 @@ namespace FishGameService.GameService Source = RewardSource.击杀Boss, IsAddRecord = false }); - _sendResult.RoomKillNotice(roomId, fishNumber, player.UserId, skillGold, fihsId, timespan, res.beishu, gold, fishConfig.FishScore, drop: new { item.PropNumber, propName, item.Count }); + _sendResult.RoomKillNotice(roomId, fishNumber, player.UserId, skillGold, fihsId, timespan, res.beishu, gold, fishConfig.FishScore, drop: new { item.PropNumber, propName, item.Count }, exp: player.Exp, level: player.Level, expMax: player.MaxExp); } } } diff --git a/deploy/Dockerfile.build b/deploy/Dockerfile.build new file mode 100644 index 0000000..bce70b3 --- /dev/null +++ b/deploy/Dockerfile.build @@ -0,0 +1,67 @@ +# ============================================================ +# 容器内编译 + 打包镜像(多阶段) +# +# 用途:本机构建时使用。编译发生在 Docker 的 Linux 虚拟机内, +# Windows 上的杀软(如火绒)无法拦截中间产物 AdminApi.dll 等, +# 可规避「dll 生成后被删除导致 MSB3030」的问题。 +# +# 由 deploy/build-local.ps1 调用,构建上下文 = 仓库根目录: +# docker build -f deploy/Dockerfile.build \ +# --build-arg PROJECT=<项目csproj相对路径,正斜杠> \ +# --build-arg SVC_NAME=<服务名,如 admin> \ +# --build-arg APP_DLL=<入口dll,如 AdminHost.dll> \ +# --build-arg PUBLIC_HOST=<捕鱼对外地址> \ +# -t 0524<服务名> . +# ============================================================ +ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:8.0 +ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:8.0 + +# ---------- 阶段一:编译发布 ---------- +FROM ${SDK_IMAGE} AS build +ARG PROJECT +ARG SVC_NAME +ARG PUBLIC_HOST=localhost + +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + DOTNET_NOLOGO=1 + +WORKDIR /src +COPY . . + +# 编译(不依赖 PUBLIC_HOST,可复用构建缓存) +RUN dotnet publish "${PROJECT}" -c Release -o /out --nologo + +# 部署配置覆盖 + 对外地址替换(单独一层:日后只改地址可秒级重建,无需重编译) +RUN set -eux; \ + # 部署版配置覆盖(各 Host 的 Config/system.json 默认不随发布输出) + mkdir -p /out/Config; \ + cp "/src/deploy/config/${SVC_NAME}/system.json" /out/Config/system.json; \ + cp /src/nlog.config /out/nlog.config; \ + # 捕鱼对局层:替换客户端要访问的对外地址 + sed -i "s|__PUBLIC_HOST__|${PUBLIC_HOST}|g" /out/Config/system.json; \ + # 网关:ocelot 下游地址由 localhost:524x 改写为「容器名:80」 + if [ -f /out/ocelot.json ]; then \ + sed -i \ + -e 's|{ "Host": "localhost", "Port": 5241 }|{ "Host": "0524admin", "Port": 80 }|' \ + -e 's|{ "Host": "localhost", "Port": 5245 }|{ "Host": "0524user", "Port": 80 }|' \ + -e 's|{ "Host": "localhost", "Port": 5247 }|{ "Host": "0524customer", "Port": 80 }|' \ + -e 's|{ "Host": "localhost", "Port": 5242 }|{ "Host": "0524chess", "Port": 80 }|' \ + -e 's|{ "Host": "localhost", "Port": 5244 }|{ "Host": "0524fishroom", "Port": 80 }|' \ + -e 's|{ "Host": "localhost", "Port": 5248 }|{ "Host": "0524fishgame", "Port": 80 }|' \ + /out/ocelot.json; \ + fi + +# ---------- 阶段二:运行时镜像 ---------- +FROM ${RUNTIME_IMAGE} +ARG APP_DLL + +ENV APP_DLL=${APP_DLL} \ + ASPNETCORE_URLS=http://+:80 \ + ASPNETCORE_ENVIRONMENT=Production \ + TZ=Asia/Shanghai + +WORKDIR /app +COPY --from=build /out . + +EXPOSE 80 +ENTRYPOINT ["sh", "-c", "exec dotnet ${APP_DLL}"] diff --git a/deploy/build-local.ps1 b/deploy/build-local.ps1 new file mode 100644 index 0000000..d481bea --- /dev/null +++ b/deploy/build-local.ps1 @@ -0,0 +1,157 @@ +<# + 本机构建镜像并导出 tar(编译在 Docker 容器内进行) + ------------------------------------------------------------------ + 适用场景:服务器无法访问 mcr.microsoft.com;且本机杀软(如火绒) + 会拦截/删除编译产物(AdminApi.dll 等)。 + 编译与镜像构建全部在容器内完成,Windows 杀软无法拦截中间产物。 + + 用法: + # 1) 全量构建(7 个服务) + powershell -ExecutionPolicy Bypass -File deploy\build-local.ps1 -PublicHost game.xxx.com + + # 2) 只构建/打包指定服务(多个逗号分隔) + powershell -ExecutionPolicy Bypass -File deploy\build-local.ps1 -PublicHost game.xxx.com -Services admin,fishgame + + # 3) 按「改动的子项目」自动推导需要重建的服务(推荐,避免漏编) + powershell -ExecutionPolicy Bypass -File deploy\build-local.ps1 -PublicHost game.xxx.com -ForProject FishGameService + + 产物:deploy\dist\ 下的 tar(只含本次构建的镜像) +#> +param( + [string]$PublicHost = "CHANGE_ME", + [string]$Tag = (Get-Date -Format "yyyyMMdd-HHmm"), + [string[]]$Services = @(), + [string]$ForProject = "", + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Deploy = Join-Path $Root "deploy" +$Dist = Join-Path $Deploy "dist" + +function Info($m) { Write-Host "[本地构建] $m" -ForegroundColor Green } +function Warn($m) { Write-Host "[警告] $m" -ForegroundColor Yellow } + +# ---------- 服务定义:服务名 -> 项目文件 / 入口 dll / Host 项目名 ---------- +$serviceMap = [ordered]@{ + gateway = @{ proj = "Gateway/Gateway.csproj"; dll = "Gateway.dll"; host = "Gateway" } + admin = @{ proj = "AdminSrc/AdminHost/AdminHost.csproj"; dll = "AdminHost.dll"; host = "AdminHost" } + user = @{ proj = "UserSrc/UserHost/UserHost.csproj"; dll = "UserHost.dll"; host = "UserHost" } + customer = @{ proj = "CustomerSrc/CustomerHost/CustomerHost.csproj"; dll = "CustomerHost.dll"; host = "CustomerHost" } + chess = @{ proj = "ChessGameHost/ChessGameHost.csproj"; dll = "ChessGameHost.dll"; host = "ChessGameHost" } + fishroom = @{ proj = "FishGameSrc/FishRoomHost/FishRoomHost.csproj"; dll = "FishRoomHost.dll"; host = "FishRoomHost" } + fishgame = @{ proj = "FishGameSrc/FishGameHost/FishGameHost.csproj"; dll = "FishGameHost.dll"; host = "FishGameHost" } +} + +# ---------- 依赖图:改动某项目 -> 需要重建的服务 ---------- +function Get-AffectedServices([string]$changedProject) { + $refs = @{} + Get-ChildItem -Path $Root -Recurse -Filter *.csproj -ErrorAction SilentlyContinue | ForEach-Object { + $txt = [IO.File]::ReadAllText($_.FullName) + $deps = @() + foreach ($m in [regex]::Matches($txt, 'ProjectReference\s+Include="([^"]+)"')) { + $deps += [IO.Path]::GetFileNameWithoutExtension($m.Groups[1].Value) + } + $refs[$_.BaseName] = $deps + } + $result = @() + foreach ($name in $serviceMap.Keys) { + $hostName = $serviceMap[$name].host + if ($hostName -eq $changedProject) { $result += $name; continue } + $seen = New-Object System.Collections.Generic.HashSet[string] + $stack = New-Object System.Collections.Stack + foreach ($d in @($refs[$hostName])) { if ($d) { [void]$stack.Push($d) } } + while ($stack.Count -gt 0) { + $cur = $stack.Pop() + if (-not $seen.Add($cur)) { continue } + foreach ($d in @($refs[$cur])) { if ($d) { [void]$stack.Push($d) } } + } + if ($seen.Contains($changedProject)) { $result += $name } + } + return $result +} + +# ---------- 1) 决定要构建哪些服务 ---------- +if ($ForProject) { + $targets = @(Get-AffectedServices $ForProject) + if ($targets.Count -eq 0) { + throw "没有任何 Host 引用项目 '$ForProject'(项目名不带 .csproj)。请检查拼写。" + } + Info ("按项目 '$ForProject' 推导出需重建的服务: " + ($targets -join ', ')) +} elseif ($Services.Count -gt 0) { + $svcList = @() + foreach ($s in $Services) { $svcList += ($s -split ',') } + $svcList = @($svcList | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + foreach ($s in $svcList) { + if (-not $serviceMap.Contains($s)) { throw "未知服务名 '$s'。可选: $($serviceMap.Keys -join ', ')" } + } + $targets = @($svcList) + Info ("指定构建服务: " + ($targets -join ', ')) +} else { + $targets = @($serviceMap.Keys) + Info "全量构建 7 个服务" +} + +if ($DryRun) { + Info ("[DryRun] 将构建的服务: " + ($targets -join ', ')) + exit 0 +} + +# ---------- 2) 前置校验 + 准备基础镜像(经镜像站拉取并打回官方 tag)---------- +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "未找到 docker,请先安装并启动 Docker Desktop" } +$prevEap = $ErrorActionPreference +$ErrorActionPreference = "Continue" +docker info 2>&1 | Out-Null +$dockerOk = ($LASTEXITCODE -eq 0) +$ErrorActionPreference = $prevEap +if (-not $dockerOk) { throw "Docker 引擎未就绪,请先启动 Docker Desktop 并等托盘图标变绿" } +if ($PublicHost -eq "CHANGE_ME") { + Warn "未指定 -PublicHost,捕鱼对局层地址会被写成 CHANGE_ME,客户端将连不上" +} +$baseImages = [ordered]@{ + "mcr.microsoft.com/dotnet/sdk:8.0" = "mcr.m.daocloud.io/dotnet/sdk:8.0" + "mcr.microsoft.com/dotnet/aspnet:8.0" = "mcr.m.daocloud.io/dotnet/aspnet:8.0" +} +foreach ($official in $baseImages.Keys) { + docker image inspect $official *> $null + if ($LASTEXITCODE -ne 0) { + Info "本地缺少 $official,经镜像站拉取 ..." + docker pull $baseImages[$official] + if ($LASTEXITCODE -ne 0) { throw "拉取 $official 失败" } + docker tag $baseImages[$official] $official + } +} + +# ---------- 3) 构建镜像(编译在容器内)---------- +foreach ($name in $targets) { + $s = $serviceMap[$name] + Info "构建镜像 0524$name ..." + docker build -f (Join-Path $Deploy "Dockerfile.build") ` + --build-arg "PROJECT=$($s.proj)" ` + --build-arg "SVC_NAME=$name" ` + --build-arg "APP_DLL=$($s.dll)" ` + --build-arg "PUBLIC_HOST=$PublicHost" ` + -t "0524$name" $Root + if ($LASTEXITCODE -ne 0) { throw "构建 0524$name 失败" } +} + +# ---------- 4) 导出 tar(只含本次构建的镜像)---------- +New-Item -ItemType Directory -Path $Dist -Force | Out-Null +$isAll = ($targets.Count -eq $serviceMap.Count) +$suffix = if ($isAll) { "" } else { "-" + ($targets -join "-") } +$tar = Join-Path $Dist "buyu-images$suffix-$Tag.tar" +$tags = @($targets | ForEach-Object { "0524$_" }) +Info ("导出镜像到 $tar ...(含: " + ($tags -join ', ') + ")") +docker save -o $tar @tags +if ($LASTEXITCODE -ne 0) { throw "导出镜像失败" } +$sizeMB = [math]::Round((Get-Item $tar).Length / 1MB, 1) + +Write-Host "" +Info "完成!镜像包: $tar ($sizeMB MB)" +Write-Host "下一步 —— 在本机上传到服务器:" -ForegroundColor Cyan +Write-Host " ssh root@<服务器IP> `"mkdir -p /opt/buyu/deploy/dist`"" -ForegroundColor Cyan +Write-Host " scp `"$tar`" root@<服务器IP>:/opt/buyu/deploy/dist/" -ForegroundColor Cyan +Write-Host "再登录服务器执行(只重启本次涉及的服务):" -ForegroundColor Cyan +Write-Host " cd /opt/buyu" -ForegroundColor Cyan +Write-Host (" bash deploy/load-and-up.sh deploy/dist/" + (Split-Path $tar -Leaf) + " " + ($targets -join ' ')) -ForegroundColor Cyan diff --git a/deploy/build-middleware.ps1 b/deploy/build-middleware.ps1 new file mode 100644 index 0000000..25d0bf5 --- /dev/null +++ b/deploy/build-middleware.ps1 @@ -0,0 +1,70 @@ +<# + 本机拉取中间件镜像并导出 tar + ------------------------------------------------------------------ + 适用场景:服务器访问不了 docker.io(拉 mongo/redis/postgres/rabbitmq 报 + "error from registry: unknown error"),改为在本机拉好后传过去。 + + 用法: + powershell -ExecutionPolicy Bypass -File deploy\build-middleware.ps1 + 产物: + deploy\dist\middleware.tar + + 随后在服务器执行: + docker load -i middleware.tar + docker compose -f deploy/docker-compose.yml up -d +#> +param( + [string]$OutFile = "", + [string[]]$Images = @("redis:7", "mongo:6.0", "postgres:16", "rabbitmq:3.13-management") +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Dist = Join-Path $Root "deploy\dist" +if (-not $OutFile) { $OutFile = Join-Path $Dist "middleware.tar" } + +$mirrors = @("docker.m.daocloud.io", "docker.1ms.run", "docker.xuanyuan.me", "hub.rat.dev") + +function Info($m) { Write-Host "[中间件] $m" -ForegroundColor Green } + +function Invoke-Quiet([scriptblock]$sb) { + $prev = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $sb 2>&1 | Out-Null + $code = $LASTEXITCODE + $ErrorActionPreference = $prev + return $code +} + +# ---------- 引擎检查 ---------- +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "未找到 docker,请先安装并启动 Docker Desktop" } +if ((Invoke-Quiet { docker info }) -ne 0) { throw "Docker 引擎未就绪,请先启动 Docker Desktop 并等托盘图标变绿" } + +# ---------- 拉取镜像(逐个镜像站回退)---------- +foreach ($img in $Images) { + if ((Invoke-Quiet { docker image inspect $img }) -eq 0) { Info "已存在,跳过: $img"; continue } + + $done = $false + foreach ($m in $mirrors) { + Info "拉取 $img (经 $m)..." + if ((Invoke-Quiet { docker pull "$m/library/$img" }) -eq 0) { + Invoke-Quiet { docker tag "$m/library/$img" $img } | Out-Null + Info "OK: $img <= $m" + $done = $true + break + } + } + if (-not $done) { throw "镜像 $img 在所有镜像站都拉取失败,请稍后重试" } +} + +# ---------- 导出 tar ---------- +New-Item -ItemType Directory -Force -Path (Split-Path $OutFile) | Out-Null +Info "导出到 $OutFile (体积较大,稍候)..." +if ((Invoke-Quiet { docker save -o $OutFile @Images }) -ne 0) { throw "导出镜像失败" } +$mb = [math]::Round((Get-Item $OutFile).Length / 1MB, 1) + +Info "完成!$OutFile ($mb MB)" +Write-Host "下一步 —— 本机上传:" -ForegroundColor Cyan +Write-Host " scp `"$OutFile`" root@<服务器IP>:/opt/buyu/" -ForegroundColor Cyan +Write-Host "服务器执行:" -ForegroundColor Cyan +Write-Host " cd /opt/buyu && docker load -i middleware.tar && docker compose -f deploy/docker-compose.yml up -d" -ForegroundColor Cyan diff --git a/deploy/load-and-up.sh b/deploy/load-and-up.sh new file mode 100644 index 0000000..1760dea --- /dev/null +++ b/deploy/load-and-up.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# ============================================================ +# 服务器侧:加载镜像包并按需启动(无需联网、无需 sdk 镜像) +# +# 用法: +# # 1) 加载完整包并启动全部 +# bash deploy/load-and-up.sh deploy/dist/buyu-images-.tar +# +# # 2) 只加载某个服务的包并只重启该服务(增量更新) +# bash deploy/load-and-up.sh deploy/dist/buyu-images-admin-.tar admin +# bash deploy/load-and-up.sh deploy/dist/buyu-images-admin-fishgame-.tar admin fishgame +# +# 说明:镜像内的配置(网关 ocelot 下游改写、捕鱼对外地址)已在构建时固化; +# 服务器只做「加载镜像 + 按需重启」,不涉及编译。 +# ============================================================ +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE="$ROOT/deploy/docker-compose.yml" +TAR="${1:-}" +shift || true +SERVICES=("$@") + +log() { echo -e "\033[1;32m[部署]\033[0m $*"; } + +[ -n "$TAR" ] || { echo "用法: bash deploy/load-and-up.sh <镜像包.tar> [服务名...]"; exit 1; } +[ -f "$TAR" ] || { echo "找不到镜像包: $TAR"; exit 1; } +command -v docker >/dev/null 2>&1 || { echo "未检测到 docker,请先安装 Docker"; exit 1; } +docker compose version >/dev/null 2>&1 || { echo "未检测到 docker compose v2"; exit 1; } + +log "1/3 加载镜像(tar 较大,需要一点时间)..." +docker load -i "$TAR" + +log "2/3 启动容器..." +mkdir -p "$ROOT/deploy"/logs/{gateway,admin,user,customer,chess,fishroom,fishgame} +mkdir -p "$ROOT/deploy"/uploads/{admin,user,customer} + +if [ ${#SERVICES[@]} -gt 0 ]; then + log "仅重建/重启: ${SERVICES[*]}" + docker compose -f "$COMPOSE" up -d --force-recreate "${SERVICES[@]}" + CHECK=("${SERVICES[@]}") +else + docker compose -f "$COMPOSE" up -d + CHECK=(gateway admin user customer chess fishroom fishgame) +fi + +log "3/3 健康检查..." +sleep 6 +declare -A PORT=( [gateway]=8001 [admin]=5241 [user]=5245 [customer]=5247 [chess]=5242 [fishroom]=5244 [fishgame]=5248 ) +for name in "${CHECK[@]}"; do + port="${PORT[$name]:-}" + [ -n "$port" ] || continue + ok="" + for _ in $(seq 1 20); do + if curl -fsS -o /dev/null --max-time 2 "http://localhost:$port/health"; then ok="✔"; break; fi + sleep 2 + done + echo " ${ok:-✘} $name http://localhost:$port/health" +done + +echo "" +log "完成。网关统一入口: http://<服务器IP或域名>:8001" diff --git a/deploy/nginx/buyu.conf b/deploy/nginx/buyu.conf new file mode 100644 index 0000000..6129927 --- /dev/null +++ b/deploy/nginx/buyu.conf @@ -0,0 +1,49 @@ +# ============================================================ +# 捕鱼/棋牌平台 - Nginx 反向代理 +# 放到宝塔站点目录(该目录下的 *.conf 被全局 nginx.conf 的 http 块 include): +# /www/server/panel/vhost/nginx/buyu.conf +# 生效:nginx -t && systemctl reload nginx +# 说明:所有后端都在本机(Docker 映射到 127.0.0.1),Nginx 只代理到本机端口。 +# ============================================================ + +# WebSocket 升级映射(名字加 buyu 前缀,避免与宝塔/其它站点冲突) +map $http_upgrade $buyu_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name 47.109.132.12; # 换成你的域名或服务器公网 IP + + client_max_body_size 50m; + + # 管理后台静态页面(AdminHost 的 wwwroot,访问 http:///admin/index.html) + location /admin/ { + proxy_pass http://127.0.0.1:5241; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 上传的图片(公告图、客服聊天图等) + location /uploads/ { + proxy_pass http://127.0.0.1:5241; + proxy_set_header Host $host; + } + + # 其余全部走网关(HTTP 接口 + SignalR WebSocket) + location / { + proxy_pass http://127.0.0.1:8001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; # SignalR 必需 + proxy_set_header Connection $buyu_upgrade; # SignalR 必需 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; # 长连接不要被掐断 + proxy_send_timeout 3600s; + } +} diff --git a/deploy/server/0524-replace.sh b/deploy/server/0524-replace.sh new file mode 100644 index 0000000..0cb21a1 --- /dev/null +++ b/deploy/server/0524-replace.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# ============================================================ +# 0524 平台:按现有约定替换/回滚单个(或多个)服务容器 +# 位置:服务器 /mnt/web/0524-replace.sh +# +# 用法: +# bash 0524-replace.sh admin # 用 0524admin:latest 重建 admin +# bash 0524-replace.sh fishgame admin # 一次替换多个 +# TAG=dual-20261008 bash 0524-replace.sh admin # 回滚 admin 到旧镜像 +# +# 约定(与线上既有部署保持一致): +# - 网络 webapi_backend +# - 端口只绑 127.0.0.1 +# - 配置挂载 /mnt/web/config -> /app/Config(全服务共用) +# - 日志挂载 /mnt/web/logs -> /app/logs +# - ASPNETCORE_URLS 按各服务内部端口 +# ============================================================ +set -u + +NET=webapi_backend +CFG=/mnt/web/config +LOGS=/mnt/web/logs +UPLOADS=/mnt/web/uploads +TAG="${TAG:-latest}" +ROLLBACK_TAG="${ROLLBACK_TAG:-dual-20261008}" + +ALL="gateway admin user customer chess fishroom fishgame" + +# 容器内部监听端口 +internal_port() { + case "$1" in + gateway) echo 8001 ;; + admin) echo 5241 ;; + user) echo 5245 ;; + customer) echo 5247 ;; + chess) echo 5242 ;; + fishroom) echo 5244 ;; + fishgame) echo 8001 ;; + esac +} +# 宿主机映射端口(只绑 127.0.0.1) +host_port() { + case "$1" in + gateway) echo 8001 ;; + admin) echo 5241 ;; + user) echo 5245 ;; + customer) echo 5247 ;; + chess) echo 5242 ;; + fishroom) echo 5244 ;; + fishgame) echo 5248 ;; + esac +} +# 额外的 -v/-e 参数 +extra_args() { + case "$1" in + gateway) echo "-v $CFG/ocelot.json:/app/ocelot.json -v $UPLOADS:/app/uploads" ;; + admin) echo "-v $UPLOADS:/app/wwwroot/uploads" ;; + customer) echo "-v $UPLOADS:/app/wwwroot/uploads" ;; + *) echo "" ;; + esac +} +# INSTANCE_ID(日志文件名里用于区分实例) +instance_id() { + case "$1" in + gateway) echo "" ;; + fishgame) echo "1" ;; + *) echo "Container_Port_$(host_port "$1")" ;; + esac +} + +run_container() { + local svc=$1 tag=$2 + local ip hp inst extra + ip=$(internal_port "$svc"); hp=$(host_port "$svc"); inst=$(instance_id "$svc"); extra=$(extra_args "$svc") + + docker rm -f "0524$svc" >/dev/null 2>&1 + local envar="ASPNETCORE_URLS=http://+:$ip" + [ -n "$inst" ] && envar="$envar -e INSTANCE_ID=$inst" + + # shellcheck disable=SC2086 + docker run -d --name "0524$svc" --restart always --network "$NET" \ + -e $envar $extra \ + -p "127.0.0.1:$hp:$ip" \ + -v "$CFG:/app/Config" -v "$LOGS:/app/logs" -v "$CFG/nlog.config:/app/nlog.config" \ + "0524$svc:$tag" >/dev/null +} + +health() { + local hp; hp=$(host_port "$1") + for _ in $(seq 1 15); do + local c; c=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$hp/health") + [ "$c" = "200" ] && return 0 + sleep 2 + done + return 1 +} + +replace_one() { + local svc=$1 + echo "=== 替换 $svc (0524$svc:$TAG)===" + run_container "$svc" "$TAG" + if health "$svc"; then + echo " ✔ $svc 健康检查通过" + else + echo " ✘ $svc 未通过,输出日志并回滚到 $ROLLBACK_TAG" + docker logs --tail 40 "0524$svc" 2>&1 | tail -20 + run_container "$svc" "$ROLLBACK_TAG" + health "$svc" && echo " ↩ 已回滚,$svc 恢复正常" || echo " !! 回滚后仍不健康,请人工介入" + return 1 + fi +} + +if [ $# -eq 0 ]; then + echo "用法: bash 0524-replace.sh <服务...> (可选服务: $ALL)" + echo " TAG=dual-20261008 bash 0524-replace.sh <服务...> # 回滚" + exit 1 +fi + +for svc in "$@"; do + case " $ALL " in + *" $svc "*) replace_one "$svc" ;; + *) echo "未知服务: $svc(可选: $ALL)" ;; + esac +done + +echo "" +echo "=== 当前业务容器 ===" +docker ps --format '{{.Names}} => {{.Image}} => {{.Status}}' | grep 0524