From 7f17dc826ed31e4acd97a308ec13001c400aae4f Mon Sep 17 00:00:00 2001 From: congsh Date: Tue, 21 Apr 2026 16:12:18 +0800 Subject: [PATCH] refactor(deploy): separate Dev/UAT into independent full-stack environments - Each environment now runs its own PB + LiveKit + Voice Token + frontend - UAT LiveKit: 7890, Voice Token: 7893 (separate from Dev 7880/7883) - Remove docker-compose.backend.yml, merge into dev compose - Delete duplicate bulletin migration files that caused PB crash on startup - Update CLAUDE.md, nginx configs, and .env files accordingly Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 31 +++-- .../1776700000_created_bulletin_posts.js | 122 ------------------ .../1776700001_created_bulletin_reads.js | 60 --------- .../1776749288_updated_game_blacklist.js | 16 +++ deploy-backend.sh | 10 -- deploy-dev.sh | 14 +- deploy-uat.sh | 14 +- docker-compose.backend.yml | 55 -------- docker-compose.dev.yml | 53 ++++++++ docker-compose.uat.yml | 16 +-- frontend/.env.dev | 2 +- frontend/.env.uat | 4 +- frontend/nginx.conf | 2 +- frontend/nginx.uat.conf | 2 +- stop-all.sh | 4 +- 15 files changed, 124 insertions(+), 281 deletions(-) delete mode 100644 backend/pb_migrations/1776700000_created_bulletin_posts.js delete mode 100644 backend/pb_migrations/1776700001_created_bulletin_reads.js create mode 100644 backend/pb_migrations/1776749288_updated_game_blacklist.js delete mode 100644 deploy-backend.sh delete mode 100644 docker-compose.backend.yml diff --git a/CLAUDE.md b/CLAUDE.md index 6f8f4e7..79797ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,9 +18,8 @@ cd frontend && npm run build cd frontend && npm run dev # 部署脚本(根目录) -./deploy-backend.sh # 部署 PocketBase + LiveKit + Voice Token 后端 -./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033) -./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712) +./deploy-dev.sh # 构建 + 部署 Dev 全套 (PB + LiveKit + 前端, 端口 7033/8090) +./deploy-uat.sh # 构建 + 部署 UAT 全套 (PB + LiveKit + 前端, 端口 7034/8712) ./stop-all.sh # 停止所有服务 # Electron 桌面端 @@ -31,11 +30,14 @@ cd electron && npm run start:uat # 连接 UAT 环境 cd electron && npm run build # 打包 Windows 可执行文件 # 查看日志 -docker logs -f gamegroup-pb # 后端 -docker logs -f gamegroup-livekit # LiveKit 语音服务 -docker logs -f gamegroup-voice-token # Voice Token 服务 -docker logs -f gamegroup-frontend-dev # Dev -docker logs -f gamegroup-frontend-uat # UAT +docker logs -f gamegroup-pb # Dev PocketBase +docker logs -f gamegroup-pb-uat # UAT PocketBase +docker logs -f gamegroup-livekit-dev # Dev LiveKit +docker logs -f gamegroup-livekit-uat # UAT LiveKit +docker logs -f gamegroup-voice-token-dev # Dev Voice Token +docker logs -f gamegroup-voice-token-uat # UAT Voice Token +docker logs -f gamegroup-frontend-dev # Dev 前端 +docker logs -f gamegroup-frontend-uat # UAT 前端 ``` **重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。 @@ -56,10 +58,10 @@ docker logs -f gamegroup-frontend-uat # UAT |------|-----|-----| | 前端 (nginx) | 7033 | 7034 | | PocketBase | 8090 | 8712 | -| LiveKit | 7880/7881/7882 | 7880/7881/7882 | -| Voice Token | 7882 | 7882 | +| LiveKit | 7880/7881/7882(udp) | 7890/7891/7892(udp) | +| Voice Token | 7883 | 7893 | -Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。 +Docker Compose 文件:`docker-compose.dev.yml`(Dev 全套)、`docker-compose.uat.yml`(UAT 全套),共享 `gamegroup-net` 网络。每个环境独立运行完整的 PB + LiveKit + Voice Token + 前端服务。 前端 `.env` 文件:`frontend/.env.dev`(VITE_PB_URL=8711, PORT=7033)和 `frontend/.env.uat`(VITE_PB_URL=8711, PORT=7034)。注意 Dev/UAT 构建时 VITE_PB_URL 都指向 8711,因为 Docker 构建时会将其覆盖为空,由 nginx 反向代理处理。 @@ -76,7 +78,7 @@ pocketbase.ts (PB 客户端初始化) ``` - **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()` -- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`, `voice.ts`) +- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`, `voice.ts`, `bulletins.ts`) - **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`) - **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理 - **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制 @@ -94,7 +96,7 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), ### Vite 代理 vs Nginx -开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 7882 端口。 +开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 voice-token 端口(Dev: 7883, UAT: 7893)。 前端 nginx 配置有两份:`nginx.conf`(dev,代理到 8090)和 `nginx.uat.conf`(代理到 8712),Docker 构建时通过 `NGINX_CONF` build arg 选择。静态资源缓存一年,HTML 不缓存。 @@ -105,7 +107,7 @@ Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), 2. `useVoiceRoom.connect(sessionId)` 调用 `api/voice.ts` 的 `fetchVoiceToken` 3. `fetchVoiceToken` 向后端 `/voice-api/voice-token/:sessionId` 请求 token 4. Voice Token 服务验证用户 PB token → 查询 `team_sessions` 确认成员身份 → 签发 LiveKit JWT -5. 前端用 token 连接 LiveKit server (`ws://192.168.1.14:7880`) +5. 前端用 token 连接 LiveKit server(Dev: `ws://192.168.1.14:7880`, UAT: `ws://192.168.1.14:7890`) HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给出明确的 Chrome flags 引导错误提示。 @@ -138,6 +140,7 @@ HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给 - **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差) - **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等) - **notifications** — 站内通知(投票/组队/入群等事件) +- **bulletin_posts** / **bulletin_reads** — 群组信息公示板,支持置顶/优先级/已读追踪/过期时间 ### PocketBase 注意事项 diff --git a/backend/pb_migrations/1776700000_created_bulletin_posts.js b/backend/pb_migrations/1776700000_created_bulletin_posts.js deleted file mode 100644 index 6e4748e..0000000 --- a/backend/pb_migrations/1776700000_created_bulletin_posts.js +++ /dev/null @@ -1,122 +0,0 @@ -/// -migrate((db) => { - const collection = new Collection({ - "id": "bulletin_posts", - "created": "2026-04-21 00:00:00.000Z", - "updated": "2026-04-21 00:00:00.000Z", - "name": "bulletin_posts", - "type": "base", - "system": false, - "schema": [ - { - "system": false, - "id": "bp_group", - "name": "group", - "type": "relation", - "required": true, - "presentable": false, - "unique": false, - "options": { - "collectionId": "es63bkyiblpnxdf", - "cascadeDelete": true, - "minSelect": null, - "maxSelect": 1, - "displayFields": null - } - }, - { - "system": false, - "id": "bp_creator", - "name": "creator", - "type": "relation", - "required": true, - "presentable": false, - "unique": false, - "options": { - "collectionId": "_pb_users_auth_", - "cascadeDelete": true, - "minSelect": null, - "maxSelect": 1, - "displayFields": null - } - }, - { - "system": false, - "id": "bp_title", - "name": "title", - "type": "text", - "required": true, - "presentable": false, - "unique": false, - "options": { - "min": 1, - "max": 200, - "pattern": "" - } - }, - { - "system": false, - "id": "bp_content", - "name": "content", - "type": "text", - "required": true, - "presentable": false, - "unique": false, - "options": { - "min": 1, - "max": 5000, - "pattern": "" - } - }, - { - "system": false, - "id": "bp_priority", - "name": "priority", - "type": "select", - "required": true, - "presentable": false, - "unique": false, - "options": { - "maxSelect": 1, - "values": ["low", "normal", "high", "urgent"] - } - }, - { - "system": false, - "id": "bp_pinned", - "name": "pinned", - "type": "bool", - "required": false, - "presentable": false, - "unique": false, - "options": {} - }, - { - "system": false, - "id": "bp_expires_at", - "name": "expiresAt", - "type": "date", - "required": false, - "presentable": false, - "unique": false, - "options": { - "min": "", - "max": "" - } - } - ], - "indexes": [], - "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", - "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", - "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", - "updateRule": "creator = @request.auth.id || group.owner = @request.auth.id", - "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", - "options": {} - }); - - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("bulletin_posts"); - return dao.deleteCollection(collection); -}) diff --git a/backend/pb_migrations/1776700001_created_bulletin_reads.js b/backend/pb_migrations/1776700001_created_bulletin_reads.js deleted file mode 100644 index 12162aa..0000000 --- a/backend/pb_migrations/1776700001_created_bulletin_reads.js +++ /dev/null @@ -1,60 +0,0 @@ -/// -migrate((db) => { - const collection = new Collection({ - "id": "bulletin_reads", - "created": "2026-04-21 00:00:01.000Z", - "updated": "2026-04-21 00:00:01.000Z", - "name": "bulletin_reads", - "type": "base", - "system": false, - "schema": [ - { - "system": false, - "id": "br_post", - "name": "post", - "type": "relation", - "required": true, - "presentable": false, - "unique": false, - "options": { - "collectionId": "bulletin_posts", - "cascadeDelete": true, - "minSelect": null, - "maxSelect": 1, - "displayFields": null - } - }, - { - "system": false, - "id": "br_user", - "name": "user", - "type": "relation", - "required": true, - "presentable": false, - "unique": false, - "options": { - "collectionId": "_pb_users_auth_", - "cascadeDelete": true, - "minSelect": null, - "maxSelect": 1, - "displayFields": null - } - } - ], - "indexes": [ - "CREATE UNIQUE INDEX `idx_bulletin_reads_post_user` ON `bulletin_reads` (\n `post`,\n `user`\n)" - ], - "listRule": "@request.auth.id != \"\" && user = @request.auth.id", - "viewRule": "@request.auth.id != \"\" && user = @request.auth.id", - "createRule": "@request.auth.id != \"\" && user = @request.auth.id", - "updateRule": null, - "deleteRule": "user = @request.auth.id || post.group.owner = @request.auth.id", - "options": {} - }); - - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("bulletin_reads"); - return dao.deleteCollection(collection); -}) diff --git a/backend/pb_migrations/1776749288_updated_game_blacklist.js b/backend/pb_migrations/1776749288_updated_game_blacklist.js new file mode 100644 index 0000000..8f6c39f --- /dev/null +++ b/backend/pb_migrations/1776749288_updated_game_blacklist.js @@ -0,0 +1,16 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("gblacklist_col") + + collection.updateRule = "reporter = @request.auth.id" + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("gblacklist_col") + + collection.updateRule = null + + return dao.saveCollection(collection) +}) diff --git a/deploy-backend.sh b/deploy-backend.sh deleted file mode 100644 index 6c18e47..0000000 --- a/deploy-backend.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -# 部署 PocketBase 后端 - -echo "🚀 部署 PocketBase 后端..." - -docker compose -f docker-compose.backend.yml up -d --force-recreate - -echo "✅ PocketBase 已启动" -echo "📡 API 地址: http://192.168.1.14:8711/api/" -echo "🔧 管理面板: http://192.168.1.14:8711/_/" diff --git a/deploy-dev.sh b/deploy-dev.sh index c630f82..d885349 100644 --- a/deploy-dev.sh +++ b/deploy-dev.sh @@ -1,12 +1,20 @@ #!/bin/bash -# 部署 Dev 前端 +# 部署 Dev 全套环境(PocketBase + LiveKit + voice-token + 前端) -echo "🚀 部署 Dev 前端..." +echo "🚀 部署 Dev 环境..." # 确保网络存在 docker network create gamegroup-net 2>/dev/null || true +# 先停掉旧容器(含已改名的 livekit / voice-token) +docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true + docker compose -f docker-compose.dev.yml up -d --build --force-recreate +echo "" echo "✅ Dev 环境已启动" -echo "🌐 访问地址: http://192.168.1.14:7033" +echo "🌐 前端: http://192.168.1.14:7033" +echo "📡 PB API: http://192.168.1.14:8090/api/" +echo "🔧 PB 管理面板: http://192.168.1.14:8090/_/" +echo "🎙️ LiveKit: ws://192.168.1.14:7880" +echo "🔑 Token: http://192.168.1.14:7883" diff --git a/deploy-uat.sh b/deploy-uat.sh index 516eeee..45ea89d 100644 --- a/deploy-uat.sh +++ b/deploy-uat.sh @@ -1,12 +1,20 @@ #!/bin/bash -# 部署 UAT 前端 +# 部署 UAT 全套环境(PocketBase + LiveKit + voice-token + 前端) -echo "🚀 部署 UAT 前端..." +echo "🚀 部署 UAT 环境..." # 确保网络存在 docker network create gamegroup-net 2>/dev/null || true +# 先停掉旧容器(含已改名的 livekit / voice-token) +docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true + docker compose -f docker-compose.uat.yml up -d --build --force-recreate +echo "" echo "✅ UAT 环境已启动" -echo "🌐 访问地址: http://192.168.1.14:7034" +echo "🌐 前端: http://192.168.1.14:7034" +echo "📡 PB API: http://192.168.1.14:8712/api/" +echo "🔧 PB 管理面板: http://192.168.1.14:8712/_/" +echo "🎙️ LiveKit: ws://192.168.1.14:7890" +echo "🔑 Token: http://192.168.1.14:7893" diff --git a/docker-compose.backend.yml b/docker-compose.backend.yml deleted file mode 100644 index 8c7a2ea..0000000 --- a/docker-compose.backend.yml +++ /dev/null @@ -1,55 +0,0 @@ -services: - pocketbase: - image: ghcr.io/muchobien/pocketbase:0.22.4 - container_name: gamegroup-pb - ports: - - "8090:8090" - volumes: - - ./backend/pb_data:/pb_data - - ./backend/pb_migrations:/pb_migrations - - ./backend/pb_hooks:/pb_hooks - environment: - - GO_ENV=production - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - networks: - - gamegroup-net - - livekit: - image: livekit/livekit-server:v1.10 - container_name: gamegroup-livekit - ports: - - "7880:7880" - - "7881:7881/udp" - - "7882:7882/udp" - environment: - LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi" - command: --dev --node-ip 192.168.1.14 - restart: unless-stopped - networks: - - gamegroup-net - - voice-token: - build: - context: ./backend/voice-token-service - container_name: gamegroup-voice-token - ports: - - "7882:7882" - environment: - - LIVEKIT_API_KEY=APIyxZGQjM2 - - LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi - - PB_URL=http://gamegroup-pb:8090 - restart: unless-stopped - depends_on: - - pocketbase - networks: - - gamegroup-net - -networks: - gamegroup-net: - driver: bridge diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 59750f6..58761fe 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,55 @@ services: + pocketbase-dev: + image: ghcr.io/muchobien/pocketbase:0.22.4 + container_name: gamegroup-pb + ports: + - "8090:8090" + volumes: + - ./backend/pb_data:/pb_data + - ./backend/pb_migrations:/pb_migrations + - ./backend/pb_hooks:/pb_hooks + environment: + - GO_ENV=production + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - gamegroup-net + + livekit-dev: + image: livekit/livekit-server:v1.10 + container_name: gamegroup-livekit-dev + ports: + - "7880:7880" + - "7881:7881/udp" + - "7882:7882/udp" + environment: + LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi" + command: --dev --node-ip 192.168.1.14 + restart: unless-stopped + networks: + - gamegroup-net + + voice-token-dev: + build: + context: ./backend/voice-token-service + container_name: gamegroup-voice-token-dev + ports: + - "7883:7882" + environment: + - LIVEKIT_API_KEY=APIyxZGQjM2 + - LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi + - PB_URL=http://gamegroup-pb:8090 + restart: unless-stopped + depends_on: + - pocketbase-dev + networks: + - gamegroup-net + frontend-dev: build: context: ./frontend @@ -11,6 +62,8 @@ services: environment: - NODE_ENV=production restart: unless-stopped + depends_on: + - pocketbase-dev networks: - gamegroup-net diff --git a/docker-compose.uat.yml b/docker-compose.uat.yml index b14a0a4..d880d4f 100644 --- a/docker-compose.uat.yml +++ b/docker-compose.uat.yml @@ -20,13 +20,13 @@ services: networks: - gamegroup-net - livekit: + livekit-uat: image: livekit/livekit-server:v1.10 - container_name: gamegroup-livekit + container_name: gamegroup-livekit-uat ports: - - "7880:7880" - - "7881:7881/udp" - - "7882:7882/udp" + - "7890:7880" + - "7891:7881/udp" + - "7892:7882/udp" environment: LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi" command: --dev --node-ip 192.168.1.14 @@ -34,12 +34,12 @@ services: networks: - gamegroup-net - voice-token: + voice-token-uat: build: context: ./backend/voice-token-service - container_name: gamegroup-voice-token + container_name: gamegroup-voice-token-uat ports: - - "7882:7882" + - "7893:7882" environment: - LIVEKIT_API_KEY=APIyxZGQjM2 - LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi diff --git a/frontend/.env.dev b/frontend/.env.dev index 5b40e97..6658342 100644 --- a/frontend/.env.dev +++ b/frontend/.env.dev @@ -2,4 +2,4 @@ VITE_PB_URL=http://192.168.1.14:8711 VITE_PORT=7033 VITE_LIVEKIT_URL=ws://192.168.1.14:7880 -VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882 +VITE_VOICE_TOKEN_URL=http://192.168.1.14:7883 diff --git a/frontend/.env.uat b/frontend/.env.uat index 7181004..8a80ce4 100644 --- a/frontend/.env.uat +++ b/frontend/.env.uat @@ -1,5 +1,5 @@ # UAT Environment VITE_PB_URL=http://192.168.1.14:8711 VITE_PORT=7034 -VITE_LIVEKIT_URL=ws://192.168.1.14:7880 -VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882 +VITE_LIVEKIT_URL=ws://192.168.1.14:7890 +VITE_VOICE_TOKEN_URL=http://192.168.1.14:7893 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 836a995..df8bc4e 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -32,7 +32,7 @@ server { # Voice token service proxy location /voice-api/ { - proxy_pass http://192.168.1.14:7882/api/; + proxy_pass http://192.168.1.14:7883/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/frontend/nginx.uat.conf b/frontend/nginx.uat.conf index c6095b6..fb55ea6 100644 --- a/frontend/nginx.uat.conf +++ b/frontend/nginx.uat.conf @@ -32,7 +32,7 @@ server { # Voice token service proxy location /voice-api/ { - proxy_pass http://192.168.1.14:7882/api/; + proxy_pass http://192.168.1.14:7893/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/stop-all.sh b/stop-all.sh index 57e938c..9182244 100644 --- a/stop-all.sh +++ b/stop-all.sh @@ -3,8 +3,10 @@ echo "🛑 停止所有服务..." -docker compose -f docker-compose.backend.yml down docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.uat.yml down +# 清理旧名称的残留容器 +docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true + echo "✅ 所有服务已停止"