diff --git a/.gitignore b/.gitignore index 9491a2f..a5143c7 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,9 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# ===== 部署产物(deploy/deploy.sh 运行时生成,不入库)===== +deploy/publish/ +deploy/logs/ +deploy/uploads/ \ No newline at end of file diff --git a/FishGameSrc/FishGameService/GameService/FishingGameService.cs b/FishGameSrc/FishGameService/GameService/FishingGameService.cs index 139a015..a9e9726 100644 --- a/FishGameSrc/FishGameService/GameService/FishingGameService.cs +++ b/FishGameSrc/FishGameService/GameService/FishingGameService.cs @@ -814,7 +814,7 @@ namespace FishGameService.GameService } - public async Task KillFish(string cid, string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign) + public async Task<(long gold, long gainExp, long exp, int level, long expMax)> KillFish(string cid, string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign) { return await _distributedLock.TryExecuteAsync( $"{RedisKey.DistributedLockPrefix}fish:gold:{roomId}:{userId}", @@ -822,12 +822,12 @@ namespace FishGameService.GameService retry: true); } - private async Task KillFishInternal(string cid, string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign) + private async Task<(long gold, long gainExp, long exp, int level, long expMax)> KillFishInternal(string cid, string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign) { if (!VerifySign(cid, $"{roomId}|{userId}|{fishNumber}|{fihsId}|{gold}|{timespan}|{bulletId}|{timestamp}", timestamp, sign)) { _sendResult.ErrorMessage(cid, ErrorCodes.签名错误, "签名错误"); - return 0; + return (0, 0, 0, 0, 0); } ; @@ -847,7 +847,7 @@ namespace FishGameService.GameService if (!string.IsNullOrWhiteSpace(cid)) _sendResult.ErrorMessage(cid, ErrorCodes.鱼配置不存在, "鱼配置不存在"); - return 0; + return (0, 0, 0, 0, 0); } var gameRoom = _redisService.GetGameRoom(roomId); if (gameRoom == null) @@ -855,7 +855,7 @@ namespace FishGameService.GameService if (!string.IsNullOrWhiteSpace(cid)) _sendResult.ErrorMessage(cid, ErrorCodes.房间不存在, "房间不存在"); - return 0; + return (0, 0, 0, 0, 0); } var player = gameRoom.Players.Where(c => c.UserId == userId).FirstOrDefault(); if (player == null) @@ -863,31 +863,32 @@ namespace FishGameService.GameService if (!string.IsNullOrWhiteSpace(cid)) _sendResult.ErrorMessage(cid, ErrorCodes.玩家不在房间, "玩家不在房间"); - return 0; + return (0, 0, 0, 0, 0); } if (player.State == 2) { - return player.Gold; + return (player.Gold, 0, player.Exp, player.Level, player.MaxExp); } if (!IsCallerPlayer(cid, gameRoom, player)) { _hubGame.SendUserCId(cid, "KickToMainPage", new { userId }); - return player.Gold; + return (player.Gold, 0, player.Exp, player.Level, player.MaxExp); } if (gold <= 0 || string.IsNullOrWhiteSpace(bulletId)) { _sendResult.ErrorMessage(cid, ErrorCodes.子弹参数无效, "子弹参数无效"); - return player.Gold; + return (player.Gold, 0, player.Exp, player.Level, player.MaxExp); } if (!TryConsumeBullet(roomId, userId, bulletId)) { - return player.Gold; + return (player.Gold, 0, player.Exp, player.Level, player.MaxExp); } var isKill = false; + long killExpGain = 0; var factor = 0.0; var playerFactor = await GetPlayerFactorAsync(roomId, userId); @@ -897,7 +898,7 @@ namespace FishGameService.GameService serverFishId = fishIdLong; if (!_fishSpawnService.ValidateFishHit(roomId, fishIdLong)) { - return player.Gold; + return (player.Gold, 0, player.Exp, player.Level, player.MaxExp); } } @@ -1006,7 +1007,7 @@ namespace FishGameService.GameService catch (OverflowException) { _sendResult.ErrorMessage(cid, ErrorCodes.金币参数超出范围, "金币参数超出范围"); - return player.Gold; + return (player.Gold, 0, player.Exp, player.Level, player.MaxExp); } (long gold, double beishu, object res) res; if (isKill) @@ -1062,7 +1063,39 @@ namespace FishGameService.GameService var expGain = fishConfig.FishType is FishTypeEnum.Boss鱼 or FishTypeEnum.特殊鱼 or FishTypeEnum.特殊Boss ? 1000 : fishConfig.FishScore; - _redisService.AddFishingExpProgress(roomId, player.UserId, expGain); + if (expGain > 0) + { + var expSaved = false; + try + { + var expResult = await _userService.UpdateAssets(new UserAssetParam + { + UserId = player.UserId, + Exp = expGain, + ChangeType = UserAssetChangeTypeEnum.增加, + Source = RewardSource.击杀鱼, + Remark = "捕鱼经验", + RoomInfo = roomId, + IsAddRecord = false + }); + expSaved = expResult.isok; + } + catch (Exception ex) + { + _logger.LogError(ex, "击杀经验实时入库异常 roomId={RoomId} userId={UserId} exp={Exp}", roomId, player.UserId, expGain); + } + + if (expSaved) + { + ApplyFishingExp(player, expGain); + killExpGain = expGain; + } + else + { + _logger.LogWarning("击杀经验实时入库未成功,转为 Redis 累计待离房结算 roomId={RoomId} userId={UserId} exp={Exp}", roomId, player.UserId, expGain); + _redisService.AddFishingExpProgress(roomId, player.UserId, expGain); + } + } if ((fishConfig.FishType == FishTypeEnum.Boss鱼 || fishConfig.FishType == FishTypeEnum.特殊Boss || fishConfig.FishType == FishTypeEnum.特殊鱼) && fishNumber != "12028" @@ -1135,7 +1168,21 @@ namespace FishGameService.GameService } _redisService.AddSystemGoldDay(gold - (isKill ? skillGold : 0)); - return player.Gold; + return (player.Gold, killExpGain, player.Exp, player.Level, player.MaxExp); + } + + /// + /// 击杀经验实时入库成功后,同步房间内玩家的经验/等级/经验上限(与 UserEntity.LevelVerify 的升级判定保持一致)。 + /// + private void ApplyFishingExp(RedisGamePlayer player, long gainExp) + { + player.Exp += gainExp; + var levelCfg = _gameConfig?.UserLevelConfigs?.FirstOrDefault(c => player.Exp >= c.ExpMin && player.Exp < c.ExpMax); + if (levelCfg != null) + { + player.Level = levelCfg.Level; + player.MaxExp = levelCfg.ExpMax; + } } private void AddBullets(string roomId, string userId, List bulletIds) diff --git a/FishGameSrc/FishGameService/GameService/IFishingGameService.cs b/FishGameSrc/FishGameService/GameService/IFishingGameService.cs index 7bb93f6..3272d6d 100644 --- a/FishGameSrc/FishGameService/GameService/IFishingGameService.cs +++ b/FishGameSrc/FishGameService/GameService/IFishingGameService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -25,7 +25,7 @@ namespace FishGameService.GameService Task<(List bullets, long gold)> FireCannon(string cid, string roomId, string userId, long gold, int count, long timestamp, string sign, object extra = null); - Task KillFish(string cid, string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign); + Task<(long gold, long gainExp, long exp, int level, long expMax)> KillFish(string cid, string roomId, string userId, string fishNumber, string fihsId, long gold, long timespan, string bulletId, long timestamp, string sign); Task LeaveRoom(string cid, string roomId, string userId); diff --git a/FishGameSrc/FishGameService/Hubs/FishGameHub.cs b/FishGameSrc/FishGameService/Hubs/FishGameHub.cs index 2041882..0bc9741 100644 --- a/FishGameSrc/FishGameService/Hubs/FishGameHub.cs +++ b/FishGameSrc/FishGameService/Hubs/FishGameHub.cs @@ -313,8 +313,16 @@ namespace FishGameService.Hubs { string cid = Context.ConnectionId; - var currentGold = await _fishingService.KillFish(cid, roomId, userId, fishNumber, fihsId, gold, timespan, bulletId, timestamp, sign); - return new { gold = currentGold, timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + var result = await _fishingService.KillFish(cid, roomId, userId, fishNumber, fihsId, gold, timespan, bulletId, timestamp, sign); + return new + { + gold = result.gold, + gainExp = result.gainExp, + exp = result.exp, + level = result.level, + expMax = result.expMax, + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; } public async Task LeaveRoom(string roomId, string userId) diff --git a/FishGameSrc/FishGameService/Services/FishSpawnService.cs b/FishGameSrc/FishGameService/Services/FishSpawnService.cs index dc7c64f..9e2222b 100644 --- a/FishGameSrc/FishGameService/Services/FishSpawnService.cs +++ b/FishGameSrc/FishGameService/Services/FishSpawnService.cs @@ -96,6 +96,7 @@ namespace FishGameService.Services RoomLevel = roomLevel, Config = config, }; + ResetBossSpawnState(state); var now = DateTime.UtcNow; lock (state.Lock) @@ -132,6 +133,7 @@ namespace FishGameService.Services state.Config = newConfig; state.NextSpawnByType.Clear(); + ResetBossSpawnState(state); } } @@ -893,6 +895,12 @@ namespace FishGameService.Services return spawned; } + if (prob.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼) + return SpawnBossFishByQueue(state, candidates, spawnCount, now, random, isFirst); + + if (prob.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss) + return SpawnSpecialBossByCycle(state, candidates, spawnCount, now, random, isFirst); + var maxAttempts = Math.Max(20, spawnCount * 10); for (int attempt = 0; spawned.Count < spawnCount && attempt < maxAttempts; attempt++) { @@ -921,7 +929,6 @@ namespace FishGameService.Services now < nextAllowedAt) continue; - var fishId = NextFishId(); var routeId = PickRoute(fishCfg, random, requestedOdd); if (alternateBySide && ((routeId & 1) == 1) != requestedOdd) @@ -962,49 +969,7 @@ namespace FishGameService.Services preferOdd = !preferOdd; } - var times = fishCfg.Times > 0 ? fishCfg.Times : 8; - - DateTime createdAt; - if (isFirst) - { - var pastOffset = times * (0.2 + random.NextDouble() * 0.6); - createdAt = now.AddSeconds(-pastOffset); - } - else - { - createdAt = now; - } - - var activeFish = new ActiveFish - { - FishId = fishId, - FishNumber = fishCfg.FishNumber, - FishType = fishCfg.FishType, - RouteId = routeId, - CreatedAt = createdAt, - ExpireAt = createdAt.AddSeconds(times), - IsKilled = false - }; - - state.ActiveFishes[fishId] = activeFish; - if (fishCfg.FishType == 2 || fishCfg.FishType == 5) - { - state.NextAllowedSpawnByFishNumber[fishCfg.FishNumber] = - activeFish.ExpireAt.AddSeconds(RestrictedFishRespawnBufferSeconds); - } - state.FishCountByType.TryGetValue(fishCfg.FishType, out var fc); - state.FishCountByType[fishCfg.FishType] = fc + 1; - - spawned.Add(new SpawnFishItem - { - FishId = fishId, - FishIds = new List { fishId }, - FishNumber = fishCfg.FishNumber, - FishType = fishCfg.FishType, - RouteId = routeId, - CreatedAtMs = new DateTimeOffset(createdAt).ToUnixTimeMilliseconds(), - ExpireAtMs = new DateTimeOffset(activeFish.ExpireAt).ToUnixTimeMilliseconds() - }); + spawned.Add(AddActiveFish(state, fishCfg, routeId, isFirst, now, random)); } } else if (prob.Type == 2 && config.FishArrays != null) @@ -1139,6 +1104,221 @@ namespace FishGameService.Services return spawned; } + /// + /// 重置 Boss 排队状态:Boss鱼(FishType=2)队列、回合计数、特殊Boss(FishType=5)轮询列表 + /// + private static void ResetBossSpawnState(RoomFishState state) + { + var fishes = state.Config?.Fishes ?? new List(); + + state.BossQueue = fishes + .Where(f => f.FishType == (int)CommonDomain.Enum.FishTypeEnum.Boss鱼 && + f.FishWeight > 0 && !string.IsNullOrWhiteSpace(f.FishNumber)) + .Select(f => f.FishNumber) + .Distinct() + .OrderBy(_ => Random.Shared.Next()) + .ToList(); + state.BossQueueTotal = state.BossQueue.Count; + state.BossSpawnedThisRound = new List(); + state.BossRoundBatchCount = 0; + + state.SpecialBossCycle = fishes + .Where(f => f.FishType == (int)CommonDomain.Enum.FishTypeEnum.特殊Boss && + f.FishWeight > 0 && !string.IsNullOrWhiteSpace(f.FishNumber)) + .Select(f => f.FishNumber) + .Distinct() + .OrderBy(_ => Random.Shared.Next()) + .ToList(); + } + + /// + /// Boss鱼排队出鱼:按队列依次轮流出、不重复;每 maxBosscount/2 轮(批次)为一个回合, + /// 回合结束后本回合出过的鱼重新添加到随机出 Boss 的列表中。 + /// + private List SpawnBossFishByQueue(RoomFishState state, List candidates, + int spawnCount, DateTime now, Random random, bool isFirst) + { + var spawned = new List(); + if (state.BossQueueTotal == 0 && state.BossQueue.Count == 0 && state.BossSpawnedThisRound.Count == 0) + ResetBossSpawnState(state); + + while (spawned.Count < spawnCount) + { + var requestedOdd = state.PreferOddRestrictedRoute; + var fishCfg = TakeNextEligibleBoss(state, candidates, requestedOdd, now); + if (fishCfg == null) + break; + + var routeId = PickRoute(fishCfg, random, requestedOdd); + state.PreferOddRestrictedRoute = (routeId & 1) == 0; + spawned.Add(AddActiveFish(state, fishCfg, routeId, isFirst, now, random)); + } + + state.BossRoundBatchCount++; + var roundLength = Math.Max(1, state.BossQueueTotal / 2); + if (state.BossRoundBatchCount >= roundLength) + { + if (state.BossSpawnedThisRound.Count > 0) + state.BossQueue.AddRange(state.BossSpawnedThisRound); + state.BossSpawnedThisRound.Clear(); + state.BossRoundBatchCount = 0; + } + + return spawned; + } + + /// + /// 特殊Boss轮询出鱼:每轮按随机顺序把每个特殊Boss各出一次,出完一轮重新随机排序。 + /// + private List SpawnSpecialBossByCycle(RoomFishState state, List candidates, + int spawnCount, DateTime now, Random random, bool isFirst) + { + var spawned = new List(); + + while (spawned.Count < spawnCount) + { + var requestedOdd = state.PreferOddRestrictedRoute; + var fishCfg = TakeNextEligibleSpecialBoss(state, candidates, requestedOdd, now); + if (fishCfg == null) + break; + + var routeId = PickRoute(fishCfg, random, requestedOdd); + state.PreferOddRestrictedRoute = (routeId & 1) == 0; + spawned.Add(AddActiveFish(state, fishCfg, routeId, isFirst, now, random)); + } + + return spawned; + } + + /// + /// 从 Boss 队列依次取鱼:取队头,仍活跃或处于冷却期的鱼暂缓到队尾,取出的鱼记录到本回合已出列表。 + /// + private FishSpawnFish? TakeNextEligibleBoss(RoomFishState state, List candidates, + bool requestedOdd, DateTime now) + { + var scanLimit = state.BossQueue.Count; + for (var i = 0; i < scanLimit && state.BossQueue.Count > 0; i++) + { + var fishNumber = state.BossQueue[0]; + state.BossQueue.RemoveAt(0); + + var fishCfg = candidates.FirstOrDefault(f => + string.Equals(f.FishNumber, fishNumber, StringComparison.OrdinalIgnoreCase)); + if (fishCfg == null) + continue; + + if (!HasRouteForSide(fishCfg, requestedOdd) || IsFishNumberActiveOrCooling(state, fishNumber, now)) + { + state.BossQueue.Add(fishNumber); + continue; + } + + state.BossSpawnedThisRound.Add(fishNumber); + return fishCfg; + } + + return null; + } + + /// + /// 从特殊Boss轮询列表依次取鱼:取队头,仍活跃或处于冷却期的鱼暂缓到队尾;列表取空后重新随机排序。 + /// + private FishSpawnFish? TakeNextEligibleSpecialBoss(RoomFishState state, List candidates, + bool requestedOdd, DateTime now) + { + if (state.SpecialBossCycle.Count == 0) + { + state.SpecialBossCycle = candidates + .Select(f => f.FishNumber) + .Distinct() + .OrderBy(_ => Random.Shared.Next()) + .ToList(); + } + + var scanLimit = state.SpecialBossCycle.Count; + for (var i = 0; i < scanLimit && state.SpecialBossCycle.Count > 0; i++) + { + var fishNumber = state.SpecialBossCycle[0]; + state.SpecialBossCycle.RemoveAt(0); + + var fishCfg = candidates.FirstOrDefault(f => + string.Equals(f.FishNumber, fishNumber, StringComparison.OrdinalIgnoreCase)); + if (fishCfg == null) + continue; + + if (!HasRouteForSide(fishCfg, requestedOdd) || IsFishNumberActiveOrCooling(state, fishNumber, now)) + { + state.SpecialBossCycle.Add(fishNumber); + continue; + } + + return fishCfg; + } + + return null; + } + + private static bool IsFishNumberActiveOrCooling(RoomFishState state, string fishNumber, DateTime now) + { + if (state.ActiveFishes.Values.Any(f => !f.IsKilled && f.ExpireAt > now && + string.Equals(f.FishNumber, fishNumber, StringComparison.Ordinal))) + return true; + + return state.NextAllowedSpawnByFishNumber.TryGetValue(fishNumber, out var nextAllowedAt) && now < nextAllowedAt; + } + + /// + /// 将鱼写入房间活跃鱼状态并返回出鱼项(首批鱼向前偏移创建时间,模拟已游动一段时间)。 + /// + private SpawnFishItem AddActiveFish(RoomFishState state, FishSpawnFish fishCfg, int routeId, + bool isFirst, DateTime now, Random random) + { + var times = fishCfg.Times > 0 ? fishCfg.Times : 8; + + DateTime createdAt; + if (isFirst) + { + var pastOffset = times * (0.2 + random.NextDouble() * 0.6); + createdAt = now.AddSeconds(-pastOffset); + } + else + { + createdAt = now; + } + + var fishId = NextFishId(); + var activeFish = new ActiveFish + { + FishId = fishId, + FishNumber = fishCfg.FishNumber, + FishType = fishCfg.FishType, + RouteId = routeId, + CreatedAt = createdAt, + ExpireAt = createdAt.AddSeconds(times), + IsKilled = false + }; + + state.ActiveFishes[fishId] = activeFish; + if (fishCfg.FishType == 2 || fishCfg.FishType == 5) + { + state.NextAllowedSpawnByFishNumber[fishCfg.FishNumber] = + activeFish.ExpireAt.AddSeconds(RestrictedFishRespawnBufferSeconds); + } + state.FishCountByType.TryGetValue(fishCfg.FishType, out var fc); + state.FishCountByType[fishCfg.FishType] = fc + 1; + + return new SpawnFishItem + { + FishId = fishId, + FishIds = new List { fishId }, + FishNumber = fishCfg.FishNumber, + FishType = fishCfg.FishType, + RouteId = routeId, + CreatedAtMs = new DateTimeOffset(createdAt).ToUnixTimeMilliseconds(), + ExpireAtMs = new DateTimeOffset(activeFish.ExpireAt).ToUnixTimeMilliseconds() + }; + } + private static string GetSpawnLogTypeName(int fishType) { return fishType switch @@ -1271,6 +1451,16 @@ namespace FishGameService.Services public Dictionary ActiveArrayFishCount { get; set; } = new Dictionary(); + public List BossQueue { get; set; } = new List(); + + public List BossSpawnedThisRound { get; set; } = new List(); + + public int BossQueueTotal { get; set; } + + public int BossRoundBatchCount { get; set; } + + public List SpecialBossCycle { get; set; } = new List(); + public bool PreferOddArrayNumber { get; set; } = true; public bool PreferOddRestrictedRoute { get; set; } = true; diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..1d44931 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,27 @@ +# ============================================================ +# 通用运行时镜像(7 个 Host 共用,由 deploy/deploy.sh 调用) +# +# 用法示例: +# docker build -f deploy/Dockerfile --build-arg APP_DLL=AdminHost.dll -t 0524admin deploy/publish/admin +# +# 说明: +# - 发布产物由 deploy.sh 在 dotnet/sdk 容器内编译生成,本镜像只负责运行 +# - 容器内统一监听 80 端口(与 docker-compose 的端口映射对应) +# ============================================================ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 + +ARG APP_DLL +ENV APP_DLL=${APP_DLL} \ + ASPNETCORE_URLS=http://+:80 \ + ASPNETCORE_ENVIRONMENT=Production \ + TZ=Asia/Shanghai \ + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + +# 确保可写 /app/logs 日志目录与 /app/wwwroot/uploads 上传目录 +USER root + +WORKDIR /app +COPY . . + +EXPOSE 80 +ENTRYPOINT ["sh", "-c", "exec dotnet ${APP_DLL}"] \ No newline at end of file diff --git a/deploy/config/admin/system.json b/deploy/config/admin/system.json new file mode 100644 index 0000000..d266adf --- /dev/null +++ b/deploy/config/admin/system.json @@ -0,0 +1,52 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtConfig": { + "TokenKey": "token", + "Issuer": "admin", + "Audience": "", + "SecretKey": "henanhuashengchongwuyouxiangongsikejizhongxin", + "ValidateIssuer": false, + "ValidateAudience": false, + "ValidateIssuerSigningKey": false, + "ValidateLifetime": false + }, + "ConnectionStrings": { + "PgSqlConnection": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "CapConfig": { + "Host": "rabbitmq", + "UserName": "admin", + "Password": "123456", + "VirtualHost": "/", + "PGLogHost": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + }, + "MongoDbSettings": { + "username": "root", + "password": "root23456", + "ServerUrl": "mongodb://mongo:27017" + }, + "WXConfig": { + "AppId": "wxceab9ce5b4474219", + "Secret": "5a291d7b295c76da579611da3f83e059", + "Url": "https://api.weixin.qq.com" + }, + "BulletConfig": { + "SignKey": "63215f0afa3f5b25", + "MaxBulletPerUser": 100, + "TimeWindowSeconds": 500000 + } +} \ No newline at end of file diff --git a/deploy/config/chess/system.json b/deploy/config/chess/system.json new file mode 100644 index 0000000..301fd36 --- /dev/null +++ b/deploy/config/chess/system.json @@ -0,0 +1,47 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtConfig": { + "TokenKey": "token", + "Issuer": "admin", + "Audience": "", + "SecretKey": "henanhuashengchongwuyouxiangongsikejizhongxin", + "ValidateIssuer": false, + "ValidateAudience": false, + "ValidateIssuerSigningKey": false, + "ValidateLifetime": false + }, + "ConnectionStrings": { + "PgSqlConnection": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "CapConfig": { + "Host": "rabbitmq", + "UserName": "admin", + "Password": "123456", + "VirtualHost": "/", + "PGLogHost": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + }, + "MongoDbSettings": { + "username": "root", + "password": "root23456", + "ServerUrl": "mongodb://mongo:27017" + }, + "WXConfig": { + "AppId": "wxceab9ce5b4474219", + "Secret": "5a291d7b295c76da579611da3f83e059", + "Url": "https://api.weixin.qq.com" + } +} \ No newline at end of file diff --git a/deploy/config/customer/system.json b/deploy/config/customer/system.json new file mode 100644 index 0000000..f0b7db9 --- /dev/null +++ b/deploy/config/customer/system.json @@ -0,0 +1,46 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtConfig": { + "TokenKey": "token", + "Issuer": "admin", + "Audience": "", + "SecretKey": "henanhuashengchongwuyouxiangongsikejizhongxin", + "ValidateIssuer": false, + "ValidateAudience": false, + "ValidateIssuerSigningKey": false, + "ValidateLifetime": false + }, + "ConnectionStrings": { + "PgSqlConnection": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "CapConfig": { + "Host": "rabbitmq", + "UserName": "admin", + "Password": "123456", + "VirtualHost": "/", + "PGLogHost": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + }, + "MongoDbSettings": { + "username": "root", + "password": "root23456", + "ServerUrl": "mongodb://mongo:27017" + }, + "CustomerService": { + "HubUrl": "/customerHub", + "MaxMessageSize": 65536 + } +} \ No newline at end of file diff --git a/deploy/config/fishgame/system.json b/deploy/config/fishgame/system.json new file mode 100644 index 0000000..38d1b0d --- /dev/null +++ b/deploy/config/fishgame/system.json @@ -0,0 +1,55 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtConfig": { + "TokenKey": "token", + "Issuer": "admin", + "Audience": "", + "SecretKey": "henanhuashengchongwuyouxiangongsikejizhongxin", + "ValidateIssuer": false, + "ValidateAudience": false, + "ValidateIssuerSigningKey": false, + "ValidateLifetime": false + }, + "ConnectionStrings": { + "PgSqlConnection": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "CapConfig": { + "Host": "rabbitmq", + "UserName": "admin", + "Password": "123456", + "VirtualHost": "/", + "PGLogHost": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + }, + "MongoDbSettings": { + "username": "root", + "password": "root23456", + "ServerUrl": "mongodb://mongo:27017" + }, + "WXConfig": { + "AppId": "wxceab9ce5b4474219", + "Secret": "5a291d7b295c76da579611da3f83e059", + "Url": "https://api.weixin.qq.com" + }, + "BulletConfig": { + "SignKey": "63215f0afa3f5b25", + "MaxBulletPerUser": 100, + "TimeWindowSeconds": 5 + }, + "GameServerInstances": [ + "http://__PUBLIC_HOST__/gamehub?instance=1" + ] +} \ No newline at end of file diff --git a/deploy/config/fishroom/system.json b/deploy/config/fishroom/system.json new file mode 100644 index 0000000..301fd36 --- /dev/null +++ b/deploy/config/fishroom/system.json @@ -0,0 +1,47 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtConfig": { + "TokenKey": "token", + "Issuer": "admin", + "Audience": "", + "SecretKey": "henanhuashengchongwuyouxiangongsikejizhongxin", + "ValidateIssuer": false, + "ValidateAudience": false, + "ValidateIssuerSigningKey": false, + "ValidateLifetime": false + }, + "ConnectionStrings": { + "PgSqlConnection": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "CapConfig": { + "Host": "rabbitmq", + "UserName": "admin", + "Password": "123456", + "VirtualHost": "/", + "PGLogHost": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + }, + "MongoDbSettings": { + "username": "root", + "password": "root23456", + "ServerUrl": "mongodb://mongo:27017" + }, + "WXConfig": { + "AppId": "wxceab9ce5b4474219", + "Secret": "5a291d7b295c76da579611da3f83e059", + "Url": "https://api.weixin.qq.com" + } +} \ No newline at end of file diff --git a/deploy/config/gateway/system.json b/deploy/config/gateway/system.json new file mode 100644 index 0000000..7acbb1b --- /dev/null +++ b/deploy/config/gateway/system.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Ocelot": { + "Config": "ocelot.json" + }, + "AllowedHosts": "*", + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + } +} \ No newline at end of file diff --git a/deploy/config/user/system.json b/deploy/config/user/system.json new file mode 100644 index 0000000..301fd36 --- /dev/null +++ b/deploy/config/user/system.json @@ -0,0 +1,47 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "JwtConfig": { + "TokenKey": "token", + "Issuer": "admin", + "Audience": "", + "SecretKey": "henanhuashengchongwuyouxiangongsikejizhongxin", + "ValidateIssuer": false, + "ValidateAudience": false, + "ValidateIssuerSigningKey": false, + "ValidateLifetime": false + }, + "ConnectionStrings": { + "PgSqlConnection": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "CapConfig": { + "Host": "rabbitmq", + "UserName": "admin", + "Password": "123456", + "VirtualHost": "/", + "PGLogHost": "host=pgsql;database=data_0524;user id=data_0524;password=123456;" + }, + "RedisConfig": { + "Host": "redis", + "Port": 6379, + "UserName": "", + "Password": "123456", + "Database": 1, + "ConnectionString": "redis:6379,password=123456,defaultDatabase=2,abortConnect=false" + }, + "MongoDbSettings": { + "username": "root", + "password": "root23456", + "ServerUrl": "mongodb://mongo:27017" + }, + "WXConfig": { + "AppId": "wxceab9ce5b4474219", + "Secret": "5a291d7b295c76da579611da3f83e059", + "Url": "https://api.weixin.qq.com" + } +} \ No newline at end of file diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..a94bc03 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# ============================================================ +# 捕鱼/棋牌平台 - 一键部署脚本(Linux 服务器 + Docker) +# +# 用法: +# bash deploy/deploy.sh +# PUBLIC_HOST=1.2.3.4:8001 bash deploy/deploy.sh # 指定客户端访问地址(含端口) +# +# 前置要求: +# - Linux 服务器已安装 Docker(含 docker compose v2 插件) +# - 服务器可访问 nuget.org 与 docker hub(首次编译需要联网拉取依赖) +# - 建议预留 5GB 以上磁盘空间 +# +# 脚本做的事: +# 1. 在 dotnet/sdk:8.0 容器内编译发布 7 个 Host(服务器无需安装 .NET SDK) +# 2. 覆盖各服务的部署版 Config/system.json,并把网关下游地址改写为容器名 +# 3. 构建 7 个镜像(0524gateway / 0524admin / 0524user / 0524customer / +# 0524chess / 0524fishroom / 0524fishgame) +# 4. 启动全部容器(4 个中间件 + 7 个应用)并做健康检查 +# +# 常用运维命令: +# 查看状态:docker compose -f deploy/docker-compose.yml ps +# 查看日志:docker compose -f deploy/docker-compose.yml logs -f <服务名> +# 停止全部:docker compose -f deploy/docker-compose.yml down +# ============================================================ +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEPLOY_DIR="$ROOT/deploy" +PUB_DIR="$DEPLOY_DIR/publish" +COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml" +SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0" +NUGET_VOLUME="buyu-nuget" + +log() { echo -e "\033[1;32m[部署]\033[0m $*"; } +warn() { echo -e "\033[1;33m[警告]\033[0m $*"; } + +# 客户端访问地址(用于捕鱼对局层返回给客户端的 WebSocket 地址);不传则自动取本机第一个 IP +if [ -z "${PUBLIC_HOST:-}" ]; then + SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + PUBLIC_HOST="${SERVER_IP:-localhost}:8001" +fi + +# ---------- 0. 环境检查 ---------- +command -v docker >/dev/null 2>&1 || { echo "未检测到 docker,请先安装 Docker"; exit 1; } +docker compose version >/dev/null 2>&1 || { echo "未检测到 docker compose v2,请安装 docker-compose-plugin"; exit 1; } +[ -f "$COMPOSE_FILE" ] || { echo "缺少编排文件:$COMPOSE_FILE"; exit 1; } + +# ---------- 1. 容器内编译发布 + 准备部署配置 ---------- +log "1/4 编译发布(在容器内进行,首次需要拉取 SDK 镜像与 NuGet 包,耗时较长)..." +docker run --rm \ + -v "$ROOT":/src \ + -w /src \ + -v "$NUGET_VOLUME":/root/.nuget/packages \ + -e PUBLIC_HOST="$PUBLIC_HOST" \ + "$SDK_IMAGE" bash -c ' + set -e + rm -rf /src/deploy/publish + mkdir -p /src/deploy/publish + dotnet publish Gateway/Gateway.csproj -c Release -o /src/deploy/publish/gateway --nologo + dotnet publish AdminSrc/AdminHost/AdminHost.csproj -c Release -o /src/deploy/publish/admin --nologo + dotnet publish UserSrc/UserHost/UserHost.csproj -c Release -o /src/deploy/publish/user --nologo + dotnet publish CustomerSrc/CustomerHost/CustomerHost.csproj -c Release -o /src/deploy/publish/customer --nologo + dotnet publish ChessGameHost/ChessGameHost.csproj -c Release -o /src/deploy/publish/chess --nologo + dotnet publish FishGameSrc/FishRoomHost/FishRoomHost.csproj -c Release -o /src/deploy/publish/fishroom --nologo + dotnet publish FishGameSrc/FishGameHost/FishGameHost.csproj -c Release -o /src/deploy/publish/fishgame --nologo + + # 覆盖部署版配置(各 Host 的 Config/system.json 默认不会随发布输出,必须显式拷贝) + for name in gateway admin user customer chess fishroom fishgame; do + mkdir -p /src/deploy/publish/$name/Config + cp /src/deploy/config/$name/system.json /src/deploy/publish/$name/Config/system.json + cp /src/nlog.config /src/deploy/publish/$name/nlog.config + done + + # 捕鱼对局层:替换客户端要访问的 WebSocket 地址(经网关 /gamehub 路由) + sed -i "s|__PUBLIC_HOST__|$PUBLIC_HOST|g" /src/deploy/publish/fishgame/Config/system.json + + # 网关:ocelot 下游地址由 localhost:524x 改写为「容器名:80」(容器间互访) + OCELOT=/src/deploy/publish/gateway/ocelot.json + 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 }|" \ + "$OCELOT" + ' + +# ---------- 2. 构建 7 个应用镜像 ---------- +log "2/4 构建镜像..." +build_image() { # 镜像名 入口dll 发布目录名 + docker build -f "$DEPLOY_DIR/Dockerfile" --build-arg "APP_DLL=$2" -t "$1" "$PUB_DIR/$3" + echo " ✔ $1" +} +build_image 0524gateway Gateway.dll gateway +build_image 0524admin AdminHost.dll admin +build_image 0524user UserHost.dll user +build_image 0524customer CustomerHost.dll customer +build_image 0524chess ChessGameHost.dll chess +build_image 0524fishroom FishRoomHost.dll fishroom +build_image 0524fishgame FishGameHost.dll fishgame + +# ---------- 3. 启动全部容器 ---------- +log "3/4 启动容器(4 个中间件 + 7 个应用)..." +mkdir -p "$DEPLOY_DIR"/logs/{gateway,admin,user,customer,chess,fishroom,fishgame} +mkdir -p "$DEPLOY_DIR"/uploads/{admin,user,customer} +docker compose -f "$COMPOSE_FILE" up -d + +# ---------- 4. 健康检查 ---------- +log "4/4 等待服务就绪..." +sleep 8 +check() { # 地址 服务名 + for _ in $(seq 1 15); do + if curl -fsS -o /dev/null --max-time 2 "$1"; then + echo " ✔ $2 $1" + return 0 + fi + sleep 2 + done + warn "$2 未就绪:$1(排查:docker compose -f deploy/docker-compose.yml logs $2)" +} +check "http://localhost:8001/health" gateway +check "http://localhost:5241/health" admin +check "http://localhost:5245/health" user +check "http://localhost:5247/health" customer +check "http://localhost:5242/health" chess +check "http://localhost:5244/health" fishroom +check "http://localhost:5248/health" fishgame + +echo "" +log "部署完成" +echo "----------------------------------------------------------" +echo " 统一入口(网关) : http://$PUBLIC_HOST" +echo " RabbitMQ 控制台 : http://<服务器IP>:15672(admin / 123456)" +echo " 日志目录 : deploy/logs/<服务名>/日期/" +echo " 上传文件目录 : deploy/uploads/<服务名>/" +echo " 停止全部 : docker compose -f deploy/docker-compose.yml down" +echo "----------------------------------------------------------" \ No newline at end of file diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..23c4ada --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,152 @@ +# ============================================================ +# 捕鱼/棋牌平台 - 一键部署编排(4 个中间件 + 7 个应用服务) +# +# 启动:docker compose -f deploy/docker-compose.yml up -d +# 停止:docker compose -f deploy/docker-compose.yml down +# 日志:docker compose -f deploy/docker-compose.yml logs -f <服务名> +# +# 说明: +# 1. 所有容器在同一网络(webapi_default),相互通过容器名访问: +# mongo / redis / rabbitmq / pgsql / 0524gateway / 0524admin / ... +# 2. 应用容器内部监听 80 端口(见 deploy/Dockerfile),宿主机映射 524x / 8001 +# 3. 网关的 ocelot.json 由 deploy.sh 在发布产物中改写为「容器名:80」的下游地址, +# 因此网关容器不需要 host 网络,跨平台可用 +# 4. 日志挂载到 deploy/logs/<服务名>,上传文件挂载到 deploy/uploads/<服务名> +# ============================================================ +name: webapi + +services: + # ==================== 中间件 ==================== + mongo: + image: mongo:6.0 + container_name: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: root23456 + ports: + - "27017:27017" + volumes: + - mongo-data:/data/db + + redis: + image: redis:7 + container_name: redis + restart: always + command: ["redis-server", "--requirepass", "123456", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - redis-data:/data + + rabbitmq: + image: rabbitmq:3.13-management + container_name: rabbitmq + restart: always + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: "123456" + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq-data:/var/lib/rabbitmq + + pgsql: + image: postgres:16 + container_name: pgsql + restart: always + environment: + POSTGRES_DB: data_0524 + POSTGRES_USER: data_0524 + POSTGRES_PASSWORD: "123456" + ports: + - "5432:5432" + volumes: + - pgsql-data:/var/lib/postgresql/data + + # ==================== 应用服务 ==================== + gateway: + image: 0524gateway + container_name: 0524gateway + restart: always + ports: + - "8001:80" + depends_on: + - admin + - user + - customer + - chess + - fishroom + - fishgame + volumes: + - ./logs/gateway:/app/logs + + admin: + image: 0524admin + container_name: 0524admin + restart: always + ports: + - "5241:80" + depends_on: [mongo, redis, rabbitmq, pgsql] + volumes: + - ./logs/admin:/app/logs + - ./uploads/admin:/app/wwwroot/uploads + + user: + image: 0524user + container_name: 0524user + restart: always + ports: + - "5245:80" + depends_on: [mongo, redis, rabbitmq, pgsql] + volumes: + - ./logs/user:/app/logs + - ./uploads/user:/app/wwwroot/uploads + + customer: + image: 0524customer + container_name: 0524customer + restart: always + ports: + - "5247:80" + depends_on: [mongo, redis, rabbitmq, pgsql] + volumes: + - ./logs/customer:/app/logs + - ./uploads/customer:/app/wwwroot/uploads + + chess: + image: 0524chess + container_name: 0524chess + restart: always + ports: + - "5242:80" + depends_on: [mongo, redis, rabbitmq, pgsql] + volumes: + - ./logs/chess:/app/logs + + fishroom: + image: 0524fishroom + container_name: 0524fishroom + restart: always + ports: + - "5244:80" + depends_on: [mongo, redis, rabbitmq, pgsql] + volumes: + - ./logs/fishroom:/app/logs + + fishgame: + image: 0524fishgame + container_name: 0524fishgame + restart: always + ports: + - "5248:80" # 与 Gateway/ocelot.json 保持一致(仓库内旧 compose 写的 5243 已废弃) + depends_on: [mongo, redis, rabbitmq, pgsql] + volumes: + - ./logs/fishgame:/app/logs + +volumes: + mongo-data: + redis-data: + rabbitmq-data: + pgsql-data: \ No newline at end of file