补回 swagger(Swashbuckle 6.6.2 + XML 注释)、socket.html 入库、固化本机构建与服务器替换脚本

- Directory.Build.props: 生成 XML 注释供 Swagger 展示中文说明
- AdminHost/FishGameHost: 接入 Swashbuckle,路由 adminswagger / roomswagger
- 后台前端 config.js 改为同源相对地址,适配反向代理
- socket.html(SignalR 协议文档)纳入仓库
- deploy/: 本机构建(容器内编译,规避杀软)、服务器替换脚本、Nginx 配置
This commit is contained in:
2026-10-18 17:28:45 +08:00
parent f0d678c5b6
commit d083d8524b
17 changed files with 1252 additions and 13 deletions
+10
View File
@@ -0,0 +1,10 @@
# Docker 构建上下文忽略项(上下文为仓库根目录,用于 deploy/Dockerfile.build 的容器内编译)
.git
.gitignore
**/bin
**/obj
deploy/publish
deploy/dist
deploy/logs
deploy/uploads
*.tar
+2 -1
View File
@@ -365,4 +365,5 @@ FodyWeavers.xsd
# ===== 部署产物(deploy/deploy.sh 运行时生成,不入库)=====
deploy/publish/
deploy/logs/
deploy/uploads/
deploy/uploads/
deploy/dist/
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -18,6 +18,7 @@
<PackageReference Include="DotNetCore.CAP.Dashboard" Version="8.3.4" />
<PackageReference Include="DotNetCore.CAP.PostgreSql" Version="8.3.4" />
<PackageReference Include="DotNetCore.CAP.RabbitMQ" Version="8.3.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
+27
View File
@@ -98,6 +98,24 @@ builder.Services.AddCronJob<DailyTaskJob>(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<ContainerBuilder>(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();
@@ -0,0 +1,624 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SignalR WebSocket 协议文档</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; font-family: -apple-system, "Microsoft YaHei", sans-serif; background: #f5f6fa; color: #333; }
.layout { display: flex; height: 100vh; }
.sidebar { width: 220px; background: #1e2a3a; color: #fff; overflow-y: auto; flex-shrink: 0; }
.sidebar .logo { padding: 20px; font-size: 18px; font-weight: bold; text-align: center; border-bottom: 1px solid #2a3a4a; }
.sidebar .nav-item { padding: 12px 20px; cursor: pointer; font-size: 14px; border-left: 3px solid transparent; }
.sidebar .nav-item:hover { background: #2a3a4a; }
.sidebar .nav-item.active { background: #2a3a4a; border-left-color: #3498db; color: #3498db; }
.sidebar .nav-item .badge { float: right; background: #3498db; color: #fff; border-radius: 10px; padding: 0 8px; font-size: 11px; line-height: 18px; }
.main { flex: 1; overflow-y: auto; padding: 20px 30px; }
.hub-section { display: none; }
.hub-section.active { display: block; }
.hub-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin-bottom: 25px; overflow: hidden; }
.hub-header { padding: 18px 20px; color: #fff; }
.hub-header .path { font-size: 20px; font-weight: bold; }
.hub-header .detail { font-size: 13px; opacity: .9; margin-top: 6px; }
h2 { font-size: 18px; margin: 25px 0 12px; padding-left: 12px; border-left: 4px solid #3498db; }
h3 { font-size: 15px; margin: 20px 0 10px; color: #555; }
table { width: 100%; border-collapse: collapse; margin-bottom: 5px; }
th { background: #f0f4f8; text-align: left; padding: 10px 12px; font-size: 13px; color: #555; border-bottom: 2px solid #ddd; }
td { padding: 10px 12px; border-bottom: 1px solid #eee; font-size: 13px; vertical-align: top; }
tr:hover td { background: #f8faff; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 11px; font-weight: bold; margin-right: 4px; }
.tag-cs { background: #e3f2fd; color: #1565c0; }
.tag-sc { background: #fce4ec; color: #c62828; }
.tag-new { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; background: #e74c3c; color: #fff; margin-left: 4px; vertical-align: middle; }
tr.row-new { border-left: 4px solid #e74c3c; background: #fff5f5 !important; }
tr.row-new td { background: #fff5f5 !important; }
tr.row-new:hover td { background: #ffe8e8 !important; }
code { background: #f0f0f0; padding: 1px 5px; border-radius: 3px; font-size: 12px; color: #c7254e; }
pre { background: #f8f9fa; padding: 12px; border-radius: 4px; overflow-x: auto; font-size: 12px; line-height: 1.6; border: 1px solid #eee; }
.info-box { background: #eef9ff; border: 1px solid #b3d8ef; border-radius: 6px; padding: 18px 22px; margin-bottom: 20px; }
ul { padding-left: 20px; } li { margin: 4px 0; font-size: 13px; }
.top-bar { background: #fff; border-radius: 8px; padding: 18px 22px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.top-bar h1 { font-size: 22px; color: #2c3e50; }
.top-bar p { color: #888; font-size: 13px; margin-top: 6px; }
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: #ccc; border-radius: 3px; }
</style>
</head>
<body>
<div class="layout">
<div class="sidebar">
<div class="logo">📡 协议文档</div>
<div class="nav-item active" data-target="hub-login" onclick="switchTab(this)">LoginHub <span class="badge">/loginhub</span></div>
<div class="nav-item" data-target="hub-fishgame" onclick="switchTab(this)">FishGameHub <span class="badge">/fishinghub</span></div>
<div class="nav-item" data-target="hub-fishroom" onclick="switchTab(this)">FishRoomHub <span class="badge">/fishingroomhub</span></div>
<div class="nav-item" data-target="hub-chessgame" onclick="switchTab(this)">ChessGameHub <span class="badge">/chessgamehub</span></div>
<div class="nav-item" data-target="hub-customer" onclick="switchTab(this)">CustomerHub <span class="badge">/customerHub</span></div>
<div class="nav-item" data-target="hub-common" onclick="switchTab(this)">公共说明</div>
</div>
<div class="main">
<div class="top-bar">
<h1>SignalR WebSocket 协议文档</h1>
<p>所有 Hub 支持 WebSocket 传输。LoginHub 和 CustomerHub 客服端需 JWT Token(<code>access_token</code> 参数)。本轮不调整现有时间字段的单位和解析方式。</p>
</div>
<!-- ==================== LoginHub ==================== -->
<div id="hub-login" class="hub-section active">
<div class="hub-card">
<div class="hub-header" style="background:#3498db;">
<div class="path">LoginHub — <code>/loginhub</code></div>
<div class="detail">WS + JWT,用于大厅资产同步、全服广播、邮件通知</div>
</div>
<h3>📤 客户端 → 服务器</h3>
<table>
<tr><th style="width:80px">方法</th><th style="width:280px">参数</th><th>说明</th></tr>
<tr><td><span class="tag tag-cs">C→S</span> Bind</td><td><code>string userId</code></td><td>必须:连接后立即调用,将连接与 userId 绑定。服务端加入 userId 命名的 SignalR 组,后续定向推送通过此组发送。</td></tr>
<tr class="row-new"><td><span class="tag tag-cs">C→S</span> DismissPendingRoom<span class="tag-new">NEW</span></td><td><code>string userId</code></td><td>用户拒绝返回断线房间。登录后若收到 PendingRoomNotice,用户选择"离开"时调用此方法,服务端清理旧房间残留。</td></tr>
</table>
<h3>📥 服务器 → 客户端</h3>
<table>
<tr><th style="width:150px">事件</th><th>数据</th><th>发送时机</th></tr>
<tr><td><span class="tag tag-sc">S→C</span> Broadcast</td>
<td><pre>{
UserName: string, // 昵称
AvatarUrl: string, // 头像URL
Level: int, // 等级
VipLevel: int, // VIP等级
Message: string, // 消息内容
Time: long // Unix时间戳(秒)
}</pre></td><td>大喇叭全服广播</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> KillAnnouncement</td>
<td><pre>{
Text: string, // 公告文案: "恭喜玩家momo在风雷之渊高级场捕获 金龙*2000炮倍 100000000大奖"
UserName: string, // 玩家名: "momo"
GameTypeName: string, // 游戏类型: "风雷之渊"
RoomName: string, // 房间名: "高级场"
FishName: string, // 鱼名: "金龙"
Multiple: int, // 倍数: 2000
Gold: long, // 获得金币: 100000000
Time: long // Unix时间戳(秒)
}</pre></td><td>全局击杀公告(特殊Boss击杀 / 机器人模拟击杀),走 LoginHub</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> AssetUpdated</td>
<td><pre>{
CurrencyType: int, // 货币类型: 1=金币 2=保险箱 3=钻石
ChangeType: int, // 变动类型: 1=增加 2=减少
Amount: long, // 变动数量
NewGold: long, // 最新金币
NewDiamond: int, // 最新钻石
Source: string, // 来源: "游戏金币结算"、"救济金"、"签到" 等
Remark: string // 备注(房间名等)
}</pre></td><td>资产变更:退出房间结算 / 后台充值扣币 / 救济金 / 签到 / 捕鱼返利</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> LevelChanged<span class="tag-new">NEW</span></td>
<td><pre>{
NewLevel: int, // 最新等级
NewExp: long, // 最新经验
NewGold: long, // 最新金币
NewDiamond: int, // 最新钻石
Source: string, // 来源
Remark: string // 备注
}</pre></td><td>后台修改玩家等级</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> BackpackUpdated</td>
<td><pre>{
Action: string, // "add" | "update" | "delete"
PropNumber: string, // 道具编号
Count: int // 数量
}</pre></td><td>道具背包变更(后台操作)</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> NewEmail</td>
<td><pre>{
Title: string, // 邮件标题
EmailId: string // 邮件ID
}</pre></td><td>收到新邮件通知</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> ChangeConfig<span class="tag-new">NEW</span></td>
<td><pre>changeType: int
1 = 活动配置更新
2 = 游戏配置更新</pre></td><td>后台活动配置或游戏配置保存成功并发布 CAP 后,由 UserHost 通过 LoginHub 向所有在线玩家广播。前端收到后应按 changeType 重新请求对应配置。监听示例:<code>connection.on("ChangeConfig", changeType =&gt; { ... })</code></td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> AccountKicked<span class="tag-new">NEW</span></td>
<td><pre>{
userId: string // 被顶的玩家ID
}</pre></td><td>账号被顶通知。该账号在其他设备登录时,旧连接收到此事件。前端应提示"账号已在其他地方登录"并清空房间 UI、断开游戏连接。</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> KickToMainPage<span class="tag-new">NEW</span></td>
<td><pre>{
userId: string // 被踢的玩家ID
}</pre></td><td>强制退出到主页面。当前账号的连接已失效(被顶号/用户不匹配),前端收到后应立即清除所有游戏 UI 并导航到主页面/登录页。</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> PendingRoomNotice<span class="tag-new">NEW</span></td>
<td><pre>{
roomId: string, // 房间ID
gameType: string, // 游戏类型: "风雷之渊"
roomName: string // 房间名: "高级场"
}</pre></td><td>登录后检测到有未完成的断线游戏。金币已结算至当前余额。前端应弹窗提示用户选择"返回房间"(调 Reconnect)或"离开"(调 DismissPendingRoom),60秒无操作自动离开。</td></tr>
</table>
</div>
</div>
<!-- ==================== FishGameHub ==================== -->
<div id="hub-fishgame" class="hub-section">
<div class="hub-card">
<div class="hub-header" style="background:#27ae60;">
<div class="path">FishGameHub — <code>/fishinghub</code></div>
<div class="detail">WS,捕鱼游戏核心 Hub。连接后先调 Reconnect 绑定用户。</div>
</div>
<p style="padding:12px 20px;margin:0;background:#f0faf0;border-bottom:1px solid #d0e8d0;"><strong>🤖 机器人说明:</strong>捕鱼房间中的机器人(<code>IsBot=true</code>)由服务端完全模拟。客户端无需发送机器人指令,机器人在协议层面与真人一致——同样触发 RoomFireNotice、RoomKillNotice 等事件。</p>
<h3>📤 客户端 → 服务器</h3>
<table>
<tr><th style="width:100px">方法</th><th style="width:380px">参数</th><th>返回</th><th>说明</th></tr>
<tr><td><span class="tag tag-cs">C→S</span> Reconnect</td><td><code>string userId</code></td><td>无</td><td>断线重连必须先调用。服务端返回 RoomInfo(含 activeFishes)并恢复房间状态</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> JoinRoom</td><td><code>string roomId, string userId, int posituin</code></td><td>无</td><td>加入房间。posituin=-1 自动分配座位</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> FireCannon</td><td><code>string roomId, string userId, long gold, int count, long timestamp, string sign, object? extra</code></td><td><pre>{ bullets: [{ bulletId: string, gold: long }], gold: long, timestamp: long }</pre></td><td>发炮。每颗返回子弹携带本次发射炮倍;外层 gold 为玩家剩余金币。sign=MD5签名</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> KillFish</td><td><code>string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign</code></td><td><pre>{ gold: long, timestamp: long }</pre></td><td>击杀鱼</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> LeaveRoom</td><td><code>string roomId, string userId</code></td><td>无</td><td>退出房间(金币结算后自动 Context.Abort 断开)</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> UseProp</td><td><code>string roomId, string userId, string propNumber, int diamond=0</code></td><td><code>int</code> 剩余数量</td><td>使用道具(钻石购买时传 diamond)。返回背包剩余数量,-1 表示失败。冰冻道具(40003)触发 FishFrozen 事件</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> ChangeGunMultiple</td><td><code>string roomId, string userId, int multiple</code></td><td>无</td><td>更改炮倍</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GetRoomInfo</td><td><code>string roomId</code></td><td>无</td><td>结果通过 GetRoomInfoResult 返回</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GetPlayerInfo</td><td><code>string roomId, string userId</code></td><td>无</td><td>获取玩家信息</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> SyncGameData</td><td><code>string roomId, object data</code></td><td>无</td><td>同步游戏数据</td></tr>
<tr class="row-new"><td><span class="tag tag-cs">C→S</span> SetPlayerAfk<span class="tag-new">NEW</span></td><td><code>string roomId, string userId</code></td><td>无</td><td>玩家挂机,触发 PlayerAfkNotice</td></tr>
<tr class="row-new"><td><span class="tag tag-cs">C→S</span> CancelPlayerAfk<span class="tag-new">NEW</span></td><td><code>string roomId, string userId</code></td><td>无</td><td>取消挂机,触发 PlayerCancelAfkNotice,返回 RoomInfo(含 activeFishes)同步当前鱼状态</td></tr>
</table>
<h3>📥 服务器 → 客户端</h3>
<table>
<tr><th style="width:170px">事件</th><th>数据</th><th>目标</th><th>说明</th></tr>
<tr><td><span class="tag tag-sc">S→C</span> BindSuccess</td><td><code>string</code></td><td>调用者</td><td>重连绑定成功</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> Exception</td><td><pre>{
code: int, // 错误码(0=未知错误)
message: string // 错误描述
}</pre><br><b>错误码说明:</b><br>1001=房间已满<br>1002=金币不足<br>1003=房间不存在<br>1004=玩家不在房间<br>1005=签名错误<br>1006=请求已过期<br>1007=鱼配置不存在<br>1008=子弹参数无效<br>1009=金币参数超出范围<br>1011=用户不存在<br>1012=道具配置不存在<br>1013=道具数量不足<br>1014=钻石不足<br>1015=炮倍参数无效</td><td>调用者</td><td>错误通知。前端根据 code 处理业务逻辑</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomInfo</td><td><pre>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
}]
}</pre></td><td>调用者</td><td>加入房间/断线重连/取消挂机返回房间信息。先用 activeFishes 初始化鱼群;服务端约 300ms 后还会向当前玩家补发同一批 OnFishSpawn(init),前端须按 fishId 去重。</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> SyncPlayerGold</td><td><code>long</code></td><td>调用者</td><td>金币同步(金币≤0时触发)</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> SendPlayerInfoResult</td><td><code>long?</code></td><td>调用者</td><td>玩家Gold值</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GetRoomInfoResult</td><td><code>RedisGameRoom?</code></td><td>调用者</td><td>房间信息查询结果</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomKillNotice</td>
<td><pre>{
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 }
}</pre></td><td>房间组</td><td>击杀通知</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomFireNotice</td><td><code>{ userId, bulletIds[], gold, count, extra, isBot }</code></td><td>房间组</td><td>发炮通知</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomJoinNotice</td><td><pre>{
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 // 是否挂机
}</pre></td><td>房间组</td><td>玩家加入</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomLeaveNotice</td><td><code>string userId</code></td><td>房间组</td><td>玩家离开</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> BotLeaveRoomNotice<span class="tag-new">NEW</span></td><td><pre>{ userId: string }</pre></td><td>房间组</td><td>机器人退出房间</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> HostDisconnect<span class="tag-new">NEW</span></td><td>无</td><td>房间组</td><td>房主断线,其他玩家可接受机器人控制权</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomDisconnectNotice</td><td><code>string userId</code></td><td>房间组</td><td>玩家断线</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> ChangeGunMultipleNotice</td><td><code>{ userId, multiple }</code></td><td>房间组</td><td>炮倍变更</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> ChangeControllerUser</td><td><code>string userId</code></td><td>房间组</td><td>房主控制权移交</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> UsePropNotice</td><td><code>{ userId, propNumber }</code></td><td>房间组</td><td>使用道具</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> SummonCardFishNotice<span class="tag-new">NEW</span></td><td><pre>{
roomId: string, // 房间ID
userId: string, // 使用召唤卡的玩家ID
userNumber: string, // 玩家编号
userName: string, // 玩家昵称
fishType: int, // 2=Boss鱼,3=黄金鱼
fishTypeName: string, // "Boss鱼"或"黄金鱼"
fishNumber: string, // 召唤出的鱼配置编号
fishId: number // 本次生成的鱼实例ID
}</pre></td><td>房间组</td><td>召唤卡成功生成鱼后广播。鱼的路径和生命周期仍由 OnFishSpawn 处理;本事件用于显示“哪个玩家召唤了什么鱼”等提示。</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> PlayerAfkNotice<span class="tag-new">NEW</span></td><td><code>{ userId: string }</code></td><td>房间组</td><td>玩家挂机通知</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> PlayerCancelAfkNotice<span class="tag-new">NEW</span></td><td><code>{ userId: string }</code></td><td>房间组</td><td>取消挂机通知</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> TorpedoResultNotice<span class="tag-new">NEW</span></td><td><pre>{ userId: string, propNumber: string, gold: long, color: string }</pre></td><td>房间组</td><td>鱼雷使用结果通知</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> OnPlayerInfo<span class="tag-new">NEW</span></td><td><pre>{ userId, userName, gold, diamond, viplvl, cannonMultiple, cannonId, wingsId, position }</pre></td><td>调用者</td><td>玩家信息同步</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> SyncGameData<span class="tag-new">NEW</span></td><td><code>object data</code></td><td>房间组</td><td>游戏数据同步(服务端主动推送)</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> FishFrozen<span class="tag-new">NEW</span></td><td>无</td><td>房间组</td><td>冰冻道具(40003)生效,鱼存活时间延长 15 秒(客户端写死 15s 倒计时)</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> OnSeatChange</td><td><pre>// 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? }</pre></td><td>房间组</td><td>座位变更(加入/离开)</td></tr>
<tr class="row-new"><td><span class="tag tag-sc">S→C</span> OnFishSpawn<span class="tag-new">NEW</span></td><td><pre>{
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 // 鱼剩余存活秒数
}]
}</pre></td><td>房间组(init/spawn)/调用者(sync)</td><td><code>fishIds</code> 在首次出鱼、定时出鱼、入房及重连同步中都必须提供。普通鱼为单元素集合 <code>[fishId]</code>;鱼阵按鱼阵配置的生成顺序使用 ID 集合。鱼阵的 <code>fishNumber</code> 为空,鱼信息由 <code>fishArrayNumber</code> 对应的本地配置确定。击中时把对应 ID 作为 KillFish 的 <code>fihsId</code> 原样传回。</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> KillNotice</td><td><pre>{
FishId: string, // 鱼编号
userName: string, // 玩家名
gold: long, // 金币
// 机器人模拟时额外字段:
fishName: string?, // 鱼名
multiplier: int? // 倍数
}</pre></td><td>全部</td><td>全局击杀公告(真人击杀/机器人模拟)</td></tr>
</table>
</div>
</div>
<!-- ==================== FishRoomHub ==================== -->
<div id="hub-fishroom" class="hub-section">
<div class="hub-card">
<div class="hub-header" style="background:#e67e22;">
<div class="path">FishRoomHub — <code>/fishingroomhub</code></div>
<div class="detail">WS,房间列表</div>
</div>
<h3>📤 客户端 → 服务器</h3>
<table>
<tr><th style="width:110px">方法</th><th style="width:380px">参数</th><th>说明</th></tr>
<tr><td><span class="tag tag-cs">C→S</span> GetRoomList</td><td><code>string userId, int gameType, int roomLevel, int roomPartition</code></td><td>获取房间列表。结果通过 GetRoomListResult 事件返回</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GetRoomInfo</td><td><code>string roomId</code></td><td>获取房间详情</td></tr>
</table>
<h3>📥 服务器 → 客户端</h3>
<table>
<tr><th style="width:170px">事件</th><th>数据</th><th>说明</th></tr>
<tr><td><span class="tag tag-sc">S→C</span> GetRoomListResult</td><td><code>List&lt;GameRoomReponse&gt;</code><br>GameRoomReponse: { RoomId, MaxPlayerCount, CurrentOnlinePlayerCount, RobotCount, RoomNumber }</td><td>房间列表(仅统计真人)</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomChange</td><td><code>RedisGameRoom</code></td><td>房间变化通知(人数、状态变更)</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GetRoomInfoResult</td><td><code>RedisGameRoom?</code></td><td>房间详情</td></tr>
</table>
</div>
</div>
<!-- ==================== ChessGameHub ==================== -->
<div id="hub-chessgame" class="hub-section">
<div class="hub-card">
<div class="hub-header" style="background:#8e44ad;">
<div class="path">ChessGameHub — <code>/chessgamehub</code></div>
<div class="detail">WS,中国象棋</div>
</div>
<p style="padding:12px 20px;margin:0;background:#fff8e6;border-bottom:1px solid #f0d58a;"><strong>🏠 房间列表说明:</strong>大厅只返回后台预配置的固定数字房间号。服务端不会再创建随机 GUID 展示房间,并会过滤、清理“无房主、无玩家”的旧异常房间。前端收到 <code>GetRoomList</code>、<code>CreateRoomNotice</code>、<code>JoinRoomNotice</code> 或 <code>ExitRoomNotice</code> 后,应按 <code>roomId</code> 更新同一条房间数据,不要追加重复房间。</p>
<h3>📤 客户端 → 服务器</h3>
<table>
<tr><th style="width:120px">方法</th><th style="width:380px">参数</th><th>说明</th></tr>
<tr><td><span class="tag tag-cs">C→S</span> CreateRoom</td><td><code>CreateRoomParam { roomId, userId, gameType(1=我方黑 2=我方红), password, gold }</code></td><td>创建房间</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> JoinGame</td><td><code>string gameId, string pwd, string userId</code></td><td>加入游戏</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GameReady</td><td><code>string gameId, string userId</code></td><td>准备</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> EndGame</td><td><code>string gameId, string winUserId</code></td><td>直接结束(对方获胜)</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> RequestEndGame</td><td><code>string gameId, string userId, string winUserId</code></td><td>请求结束(需双方确认)</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> RestartGame</td><td><code>string gameId, string userId</code></td><td>对局结束后重新开始</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GoldConfig</td><td><code>string gameId, long gold, string userId</code></td><td>房主设置金币</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> ExitRoom</td><td><code>string gameId, string userId</code></td><td>退出(房主退出清空房间,非房主保留房间)</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> Heartbeat</td><td><code>string gameId</code></td><td>心跳,更新活跃时间</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> CheckTimeout</td><td><code>string gameId</code></td><td>检查超时(60秒断线判负)</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GetRoomList</td><td><code>int partition=0</code></td><td>房间列表;0=全部分区,正数=指定分区</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GetRoomState</td><td><code>string gameId</code></td><td>获取当前房间完整状态,用于返回场景或重连后主动校准</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> KickPlayer</td><td><code>string gameId, string targetUserId</code></td><td>房主在开局前踢出指定玩家</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> SyncGameData</td><td><code>string gameId, string data</code></td><td>同步棋子移动</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> GameOperation</td><td><code>string gameId, int type, string data</code><br>type: 1=求和 2=认输(自动结束) 3=悔棋</td><td>游戏操作</td></tr>
</table>
<h3>📥 服务器 → 客户端</h3>
<table>
<tr><th style="width:170px">事件</th><th>数据</th><th>目标</th></tr>
<tr><td><span class="tag tag-sc">S→C</span> GameError</td><td><code>string</code></td><td>调用者</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> BeginGame</td><td><code>ChessGameRoom</code></td><td>调用者/房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> JoinRoom</td><td><code>ChessGameRoom</code></td><td>调用者</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> JoinRoomNotice</td><td><code>ChessGameRoom</code></td><td>象棋大厅组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RoomStateNotice</td><td><code>ChessGameRoom</code></td><td>房间组/调用者,房间完整状态</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GoldConfigRequired</td><td><code>{ targetUserId, room }</code></td><td>房间组,两名真人到齐后提示指定红方设置金币</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> Reconnect</td><td><code>{ userId, userName }</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> Disconnect</td><td><code>{ userId, disconnectTime, timeoutSeconds }</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> ReadyNotice</td><td><code>string userId</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> AllBeginTime</td><td><code>ChessGameRoom</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GameEnd</td><td><code>{ winUserId, room }</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> EndGameRequestSent</td><td><code>string</code></td><td>调用者</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> OpponentRequestEnd</td><td><code>string userId</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RestartGameSuccess</td><td><code>ChessGameRoom</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> RestartRoomNotice</td><td><code>ChessGameRoom</code></td><td>全部</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> ExitRoomNotice</td><td><code>ChessGameRoom</code></td><td>象棋大厅组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> ExitRoomSuccess</td><td><code>string userId</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> KickedFromRoom</td><td><code>{ gameId, userId }</code></td><td>被踢玩家</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> CreateRoomNotice</td><td><code>ChessGameRoom</code></td><td>象棋大厅组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GoldConfigSuccess</td><td><code>ChessGameRoom</code></td><td>房间组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GoldConfigRoomNotice</td><td><code>ChessGameRoom</code></td><td>全部</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GetRoomList</td><td><code>List&lt;ChessGameRoom&gt;</code></td><td>调用者;仅固定房间,按 roomId 覆盖本地列表</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> SyncGameData</td><td><code>string</code></td><td>全部</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> GameOperation</td><td><code>{ type, data }</code></td><td>全部</td></tr>
</table>
</div>
</div>
<!-- ==================== CustomerHub ==================== -->
<div id="hub-customer" class="hub-section">
<div class="hub-card">
<div class="hub-header" style="background:#16a085;">
<div class="path">CustomerHub — <code>/customerHub</code></div>
<div class="detail">WS,客服即时通讯。客服端需 JWT,玩家端无需 Token。</div>
</div>
<h3>📤 客户端 → 服务器</h3>
<table>
<tr><th style="width:140px">方法</th><th style="width:380px">参数</th><th>说明</th></tr>
<tr><td><span class="tag tag-cs">C→S</span> JoinConversation</td><td><code>string conversationId</code></td><td>加入会话房间组</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> LeaveConversation</td><td><code>string conversationId</code></td><td>离开会话房间组</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> SendMessage</td><td><code>string conversationId, int senderType, string senderId, string senderName, string? senderAvatar, int contentType, string content, string guId=""</code><br>senderType: 1=用户 2=客服<br>contentType: 1=文字 2=图片<br>conversationId 为空时自动创建新会话</td><td>发送消息</td></tr>
<tr><td><span class="tag tag-cs">C→S</span> StartConversation</td><td><code>string userId, string userName</code></td><td>主动发起新会话</td></tr>
</table>
<h3>📥 服务器 → 客户端</h3>
<table>
<tr><th style="width:170px">事件</th><th>数据</th><th>目标</th></tr>
<tr><td><span class="tag tag-sc">S→C</span> OnMessageReceived</td>
<td><pre>{
GuId: string,
ConversationId: string,
SenderType: int,
SenderId: string,
SenderName: string,
SenderAvatar: string?,
ContentType: int,
Content: string,
CreateTime: DateTime
}</pre></td><td>会话组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> OnConversationStarted</td><td><code>ConversationEntity</code></td><td>调用者</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> OnNewConversation</td><td><code>{ conversationId, customerId?, customerName?, userId?, userName?, status, createTime }</code></td><td>客服组</td></tr>
<tr><td><span class="tag tag-sc">S→C</span> Exception</td><td><pre>{
code: int,
message: string
}</pre></td><td>调用者</td><td>错误通知,code 同上表</td></tr>
</table>
<p style="padding:8px 12px;margin:10px 0 0;background:#f0faf0;border-radius:4px;"><strong>注意:</strong>客服连接时携带 JWT,服务端自动从 <code>uid</code> Claim 加入通知组,无需手动 JoinCustomerQueue。</p>
</div>
</div>
<!-- ==================== 公共说明 ==================== -->
<div id="hub-common" class="hub-section">
<div class="info-box">
<h3>🔗 Hub 路由总览</h3>
<table>
<tr><th>Hub</th><th>路由</th><th>认证</th></tr>
<tr><td>LoginHub</td><td><code>/loginhub</code></td><td>JWT</td></tr>
<tr><td>FishGameHub</td><td><code>/fishinghub</code></td><td>方法传参 userId</td></tr>
<tr><td>FishRoomHub</td><td><code>/fishingroomhub</code></td><td>方法传参 userId</td></tr>
<tr><td>ChessGameHub</td><td><code>/chessgamehub</code></td><td>方法传参 userId</td></tr>
<tr><td>CustomerHub</td><td><code>/customerHub</code></td><td>客服需JWT,玩家无需</td></tr>
</table>
</div>
<div class="info-box">
<h3>📦 通用数据结构</h3>
<table>
<tr><th style="width:180px">名称</th><th>字段</th></tr>
<tr><td><code>RedisGameRoom</code></td>
<td><pre>{
RoomId, RoomName, GameType, RoomLevel, RoomPartition,
RoomNumber, MaxPlayerCount, CurrentOnlinePlayerCount,
RobotCount, Players[], CreateUserId,
SystemFactor: double // 当前系统因子(根据当日盈利动态调整)
}</pre></td></tr>
<tr><td><code>RedisGamePlayer</code></td>
<td><pre>{
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
}</pre></td></tr>
<tr><td><code>ChessGameRoom</code></td>
<td><pre>{
RoomId, state(1=等待 2=游戏中 3=已结束), users[],
gameType(1=我方黑 2=我方红), gold, password,
createUserId, robotCount, isGameEnded,
timeoutSeconds(默认60), endGameRequests, pendingWinUserId
}</pre></td></tr>
<tr><td><code>ConversationEntity</code></td>
<td><pre>{
UserId?, UserName?, CustomerId?, CustomerName?,
Status(0=等待 1=进行中 2=已结束), CreateTime,
EndTime?, LastReadTime?
}</pre></td></tr>
</table>
</div>
<div class="info-box">
<h3>📤 发送基础设施(IHubSend&lt;T&gt;)</h3>
<table>
<tr><th>方法</th><th>说明</th></tr>
<tr><td><code>SendUserCId(cid, method, obj)</code></td><td>发送给指定连接</td></tr>
<tr><td><code>SendUserUserId(userId, method, obj)</code></td><td>通过 userId 查找 CId 后发送</td></tr>
<tr><td><code>SendGroup(groupId, method, obj)</code></td><td>发送给 SignalR 群组</td></tr>
<tr><td><code>SendAll(method, obj)</code></td><td>广播给所有客户端</td></tr>
<tr><td><code>GroupExcept(cid, groupId, method, obj)</code></td><td>发送给群组排除指定连接</td></tr>
</table>
</div>
<div class="info-box">
<h3>流程说明</h3>
<h4>1. 登录 / Bind 流程 (LoginHub)</h4>
<ol>
<li>客户端连接 /loginhub (JWT)</li>
<li>调用 Bind(userId),加入 userId 组</li>
<li>Bind 读取 Redis LoginCId:
<ul>
<li>若存在旧 LoginCId 且不等于当前连接 -> 顶号:
<ul>
<li>发 AccountKicked 给旧 LoginHub 连接</li>
<li>查旧 LoginHub 关联的游戏 Hub CId,发 KickToMainPage</li>
</ul>
</li>
<li>更新 Redis LoginCId = 当前连接 CId</li>
</ul>
</li>
<li>检测断线房间 (PendingRoomId):
<ul>
<li>有 -> 发 PendingRoomNotice(弹窗:返回房间 / 离开)</li>
<li>无 -> 进入大厅</li>
</ul>
</li>
</ol>
<h4>2. 顶号流程</h4>
<ul>
<li>设备 A 登录:Bind -> LoginCId = cid_A</li>
<li>设备 B 登录:Bind -> LoginCId = cid_A != cid_B -> 发 AccountKicked 到 cid_A -> 更新 LoginCId = cid_B -> A 被顶</li>
<li>设备 C 登录:Bind -> LoginCId = cid_B != cid_C -> 发 AccountKicked 到 cid_B -> 更新 LoginCId = cid_C -> B 被顶</li>
<li>关键:仅 LoginHub.Bind 负责发 AccountKicked,FishGameHub 不再发送</li>
</ul>
<h4>3. JoinRoom 流程 (FishGameHub)</h4>
<ol>
<li>客户端调 JoinRoom(roomId, userId, posituin)</li>
<li>检查玩家是否已在房间 -> 是则返回</li>
<li>获取/创建房间 roomInfo</li>
<li>判断是否首个真人进房:
<ul>
<li>是 -> RegisterRoom(roomId, roomLevel) 注册出鱼服务</li>
<li>否 -> GetActiveFishes(roomId) 获取当前活跃鱼列表</li>
</ul>
</li>
<li>发 RoomInfo(cid, room, activeFishes) 给调用者</li>
<li>发 OnSeatChange(join) 通知房间组</li>
</ol>
<h4>4. Reconnect 流程 (FishGameHub)</h4>
<ol>
<li>客户端连接 FishGameHub 后立即调 Reconnect(userId)</li>
<li>查 Redis 有无断线房间 (PendingRoomId):
<ul>
<li>有 -> 恢复玩家状态 State=0,走 JoinRoom 逻辑(含鱼同步)</li>
<li>无 -> 无操作,等待后续指令</li>
</ul>
</li>
<li>返回结果:RoomInfo(首个真人注册后的首次鱼群放入 activeFishes,约 300ms 后向当前玩家补发 OnFishSpawn(type=init))</li>
</ol>
<h4>5. CancelPlayerAfk 流程</h4>
<ol>
<li>客户端调 CancelPlayerAfk(roomId, userId)</li>
<li>清除玩家 IsAfk 标记</li>
<li>GetActiveFishes(roomId) 获取当前活跃鱼</li>
<li>发 RoomInfo(cid, room, activeFishes) 给调用者(同步鱼状态)</li>
<li>发 PlayerCancelAfkNotice 通知房间组</li>
</ol>
<h4>6. LeaveRoom / 退出流程</h4>
<ol>
<li>客户端调 LeaveRoom(roomId, userId)</li>
<li>金币结算,从房间移除玩家</li>
<li>发 RoomLeaveNotice 通知房间组</li>
<li>检查剩余真人:
<ul>
<li>无真人 -> UnregisterRoom(roomId) 注销出鱼 + 清理房间</li>
<li>有真人 -> 房主转移(若原房主退出),发 ChangeControllerUser</li>
</ul>
</li>
</ol>
<h4>7. 房主断线 / 所有权转移</h4>
<ol>
<li>房主断线触发 HandleDisconnect</li>
<li>查找下一个非挂机真人作为新房主:
<ul>
<li>找到 -> 转移所有权,发 ChangeControllerUser(新房主 userId) 通知房间组</li>
<li>未找到 -> 发 HostDisconnect(前端禁用发炮/出鱼,显示等待提示)</li>
</ul>
</li>
</ol>
<h4>8. 出鱼服务 (FishSpawnService)</h4>
<ul>
<li>首个真人进房 -> RegisterRoom -> 启动定时器持续出鱼</li>
<li>定时出鱼 -> OnFishSpawn(type=spawn) 广播房间组</li>
<li>后续真人进房/重连/取消挂机 -> GetActiveFishes -> 嵌入 RoomInfo.activeFishes 返回,并约 300ms 后补发 OnFishSpawn(type=init)</li>
<li>最后一个真人退出/断线 -> UnregisterRoom -> 停止定时器 + 清除活跃鱼</li>
</ul>
</div>
</div>
</div><!-- /main -->
</div><!-- /layout -->
<script>
function switchTab(el) {
document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
el.classList.add('active');
document.querySelectorAll('.hub-section').forEach(function(s) { s.classList.remove('active'); });
var target = document.getElementById(el.getAttribute('data-target'));
if (target) target.classList.add('active');
}
</script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
<Project>
<!--
全局构建属性
- GenerateDocumentationFile:生成 XML 注释文件,供 Swagger 展示接口的中文说明
- NoWarn 1591:抑制「公开成员缺少注释」的警告
旧线上版本(dual-20261008)即按此方式产出各项目的 .xml,此处保持一致。
-->
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
</Project>
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -17,6 +17,7 @@
<PackageReference Include="DotNetCore.CAP.Dashboard" Version="8.3.4" />
<PackageReference Include="DotNetCore.CAP.PostgreSql" Version="8.3.4" />
<PackageReference Include="DotNetCore.CAP.RabbitMQ" Version="8.3.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
+27
View File
@@ -138,6 +138,24 @@ builder.Services.AddRedisCacheSingleton(builder.Configuration);
builder.Services.Configure<BulletConfig>(builder.Configuration.GetSection("BulletConfig"));
builder.Services.AddSingleton<BulletConfig>(sp => sp.GetRequiredService<IOptions<BulletConfig>>().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<ContainerBuilder>(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();
@@ -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 });
}
@@ -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);
@@ -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);
}
}
}
+67
View File
@@ -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}"]
+157
View File
@@ -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
+70
View File
@@ -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
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# ============================================================
# 服务器侧:加载镜像包并按需启动(无需联网、无需 sdk 镜像)
#
# 用法:
# # 1) 加载完整包并启动全部
# bash deploy/load-and-up.sh deploy/dist/buyu-images-<tag>.tar
#
# # 2) 只加载某个服务的包并只重启该服务(增量更新)
# bash deploy/load-and-up.sh deploy/dist/buyu-images-admin-<tag>.tar admin
# bash deploy/load-and-up.sh deploy/dist/buyu-images-admin-fishgame-<tag>.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"
+49
View File
@@ -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://<IP>/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;
}
}
+128
View File
@@ -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