feat(deploy): Task 14 完成 docker-compose 扩展 + 环境变量双态部署

- deploy/docker-compose.dev.yml 新增 media-server 服务 + coturn(profiles:public),
  全部服务参数改为 env 变量注入,go-service 依赖 media-server
- deploy/.env.example / .env.local.example / .env.public.example 三份模板,
  公网模板用 _REPLACE_WITH_*_ 占位符防"示例值上生产"
- scripts/start.sh / stop.sh / status.sh 新增 full 子命令对接 docker compose --profile public
- scripts/deploy-public.sh 新建:env 校验 + 端口 checklist + Docker 自检 +
  --profile public up -d --build + 健康检查闭环
- docs/deployment/meeting-mvp.md 新建:双态部署全流程 + 强密码生成 + 防火墙 + FAQ
- 双模式 docker compose config 校验通过;deploy-public.sh 三种错误场景按预期退出

同步更新 CURRENT_STATUS / implementation.plan / project-context
Task 0-14  / 剩余 Task 15-16

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 17:59:48 +08:00
parent ba8c72aae0
commit a01e973335
12 changed files with 946 additions and 33 deletions

View File

@@ -27,7 +27,7 @@ alwaysApply: true
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
* 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md`
* API 文档:`docs/api/frontend/notify.md`
- 2e-2 会议 MVP约 17 天)🚧 **代码开发中**Task 0-13 ✅ / Task 14-16 待执行mediasoup Node.js 独立 `media-server/` + 即时会议≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + **会议生命周期状态机**host 宽限期 + 自动转让 + 空房 TTL+ 双态部署(本机 + 公网 coturn+ 响应式(桌面/手机)
- 2e-2 会议 MVP约 17 天)🚧 **代码开发中**Task 0-14 ✅ / Task 15-16 待执行mediasoup Node.js 独立 `media-server/` + 即时会议≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + **会议生命周期状态机**host 宽限期 + 自动转让 + 空房 TTL+ 双态部署(本机 + 公网 coturn+ 响应式(桌面/手机)
* 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`17 个 Task
* 11 项关键决策已锁定D01-D11详见设计文档 §三
* **重要修订**`meeting_rooms.password` → `password_hash`bcrypt新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
@@ -36,6 +36,7 @@ alwaysApply: true
* **Task 2 ✅ 9 个内部 REST API 完成2026-04-21含代码审查修复**`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**zod 手动 parse + 全局 errorHandler** 方案(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突);`AppError` 统一错误码(`NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR`+ `VALIDATION_ERROR`/`UNAUTHORIZED`/`INTERNAL_ERROR`;所有资源用 **Map + `observer.once('close')` 自清理**`producerclose` 级联关闭下游 consumerConsumer 强制 `paused:true` 创建 + `/resume` 独立接口direction 强约束recv transport 拒 produce、send transport 拒 consume。**code-reviewer 子代理"有条件通过"**2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验mimeType/clockRate/payloadType 必填、codecs 数组 ≥1消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await(4) m2 改读 `consumer.producerPaused`(5) m3 `producerclose` 改为 `once`(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s、覆盖率 82.87%/75.83%/91.3%/82.87%**stmts/branches/funcs/lines较首版 +2pp9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回201/200/400/401/404/409。产出`src/schemas/*`6 文件,新增 `rtp.ts`+ `src/services/*`4 文件)+ `src/routes/*`4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nitsm4/m6~m10、n1~n10登记至 Task 16 收尾清单
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成2026-04-21**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQLDDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`幂等增量升级。Go 侧 `backend/go-service/app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`3 个 model + 3 个 DAO`meeting_room_dao.go` 9 方法 / `meeting_participant_dao.go` 11 方法 / `meeting_chat_dao.go` 4 方法),共 24 个持久化方法;`JoinRoom` 事务内复用离会后的旧记录(`left_at=NULL,joined_at=NOW,duration=0`)避免审计表污染;`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走 DB 时间防跨时区漂移;`TransferHost` 事务链(`role=1→0` + `role=0→1``FindActiveByUser` 用 JOIN 校验用户单点参会;`MarkEnded` 乐观锁防重复覆盖 `ended_reason``ListExpiredForCleanup` 供后续清理任务批量扫描。**关键风格修正(偏离实施计划草案)**:按 `project-context` 第 11 条「代码风格全局一致(最高优先级)」,常量归入 `app/constants/meeting.go` 单文件(与 `group.go`/`notify.go` 同构),而非草案的 `app/meeting/constants/*.go`;时间字段统一 `TIMESTAMP(0)` 取代草案的 `TIMESTAMPTZ` 对齐项目所有现有表;冗余 `idx_meeting_rooms_code` 移除(`room_code UNIQUE` 已自动建索引)。验证:`go build ./...` / `go vet ./...` / `ReadLints` 零错误psql 集成脚本跑通 8 场景CRUD + 双 UNIQUE 约束 + 主持人转让事务 + `duration=10s` 精确匹配 + CASCADE 清零)。延续项目 Go 侧"零 `_test.go`"风格(用代码审查 + psql 真库验证 + Playwright E2E 三层守护)
* **Task 4 ✅ Go meeting 模块 service/controller/router 骨架完成2026-04-21**`app/meeting/` 补齐 service/controller/router/provider 四件套,接口 → 实现按设计文档 §5.3 一一对齐。产出:(1) `service/interfaces.go` 定义 3 个外部依赖接口(`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`),解耦 notify/contact/ws 模块避免循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单 `bool`(2) `service/meeting_service.go` 声明 `MeetingService` + 8 个 sentinel error + 17 个业务方法空实现,全部返回 `ErrNotImplemented`(3) `controller/meeting_controller.go` 12 个 Gin 处理器 + `responseNotImplemented`501+ `requireUserID` 辅助;(4) `router.go` 12 条路由挂载到 `/api/v1/meeting/*` 并统一套 `jwtAuth` 中间件;(5) `provider.go` 定义 `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`(6) 全局 `app/provider/wire.go` 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind``NotifyPusher→NotifyService` / `UserInfoResolver→FriendshipDAO` / `OnlineChecker→ws.OnlineService`(7) `app/provider/provider.go` `App` 加 `MeetingService/MeetingController` 字段;(8) `router/router.go` 调用 `meetingApp.RegisterRoutes`。**顺手修复存量 bug**`admin/provider.go` 补齐 `MessageManage{DAO,Service,Controller}` 三个 provider解决旧版 `wire` 重生成报"no provider found"的遗留问题。验证:`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;`GIN_MODE=debug` 启动 server 日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).Xxx-fm`curl 无 token 打 3 条代表性路由均返回 401 `缺少认证信息`JWT 中间件生效
* **Task 14 ✅ docker-compose 扩展 + 环境变量双态开关完成2026-04-24**EchoChat 会议 MVP 正式具备**本机 Demo / 公网部署**双态一键化能力。产出:(1) `deploy/docker-compose.dev.yml` 新增 `media-server` 服务env 注入 `MEDIASOUP_ANNOUNCED_IP` / `MEDIA_INTERNAL_TOKEN` / RTC 端口段 40000-40199+ `coturn` 服务(`profiles: ["public"]` + `network_mode: host` + env 注入 realm / user / credential / 端口段 49152-65535`go-service` 新增 `depends_on: media-server`postgres/redis/minio 全部改为 env 变量可覆盖;(2) `deploy/.env.example`(总模板,涵盖 `DEPLOY_MODE/DB/REDIS/JWT/MINIO/MEDIA/TURN` 全字段)+ `deploy/.env.local.example`(本机 Demo 预填值,`MEDIASOUP_ANNOUNCED_IP=""` 空 = 自动内网 IP`TURN_ENABLED=false`+ `deploy/.env.public.example`(公网部署模板,所有敏感字段使用 `_REPLACE_WITH_STRONG_PASSWORD_` / `_REPLACE_WITH_YOUR_PUBLIC_IP_` / `_REPLACE_WITH_TURN_SECRET_` 占位符 + 部署前 checklist(3) `scripts/start.sh` 新增 `full` 子命令(`ensure_env_file` + `start_full` → `docker compose -f docker-compose.dev.yml --profile public up -d --build``scripts/stop.sh` 新增 `full` 子命令;`scripts/status.sh` 扩展检测 `echochat-go-service/media-server/coturn` 三个应用容器;(4) `scripts/deploy-public.sh`(新建,`chmod +x``step_validate_env``.env` 存在 + `DEPLOY_MODE=public` + `MEDIASOUP_ANNOUNCED_IP` 非空 + 所有 `_REPLACE_WITH_*_` 占位符已替换)→ `step_check_ports`(本机端口 + 云厂商安全组 checklist→ `step_check_docker`daemon + Compose V2→ `step_launch`(按 `TURN_ENABLED` 决定 `--profile public` 取舍 + `wait` media-server `/healthz`(5) `docs/deployment/meeting-mvp.md`(新建):本机 Demo + 公网双态部署全流程 + coturn 配置 + 强密码生成 + 防火墙 checklist + FAQ`python3/g++` / 视频问题 / `coturn` 网络模式 / HTTPS / 数据库备份)。**关键设计决策**(a) 双态开关 = 纯 env 差异(`MEDIASOUP_ANNOUNCED_IP` 空 vs 非空 + `TURN_ENABLED` true vs false(b) coturn 在 Linux 上必须 `network_mode: host`TURN 依赖大段随机 UDP 端口)+ `profiles: ["public"]` 隔离保证本地 Demo 不浪费资源;(c) `deploy-public.sh` 扫描 `_REPLACE_WITH_*_` 占位符防"示例值上生产"事故;(d) Compose V2 `--profile` 必须作为顶层 flag`docker compose --profile public -f x.yml config`(e) 不起独立 `docker-compose.prod.yml`profile + env 分离即可。验证:`docker compose config` 两种模式local / public均 `--quiet` 通过;`deploy-public.sh` 三场景(`.env` 缺失 / 含占位符 / 空 `ANNOUNCED_IP`)全部按预期退出 1完整正确配置下顺序通过三步校验进入 `step_launch`。下一步 Task 15 UI 打磨 + 主持人权限四件套
* **Task 13 ✅ meeting_invite 通知卡片对接完成2026-04-23**:后端 `MeetingService.InviteUsers` 的 `PushPayload.Extra` 补齐设计 §10.1 要求的 `inviter_id / inviter_name / inviter_avatar / expired_at`Unix 秒,与 Redis TTL `MeetingInviteTokenTTL=600s` 一致,取代之前只有 `room_code/room_title/has_password/invite_token` 的简化版本);前端 `constants/notify.js` 把 `supportsInlineAction` 扩展支持 `meeting_invite`,新增 `NOTIFY_INLINE_ACTION_LABEL` 映射表让按钮文案按 type 动态变化meeting_invite → `立即加入 / 稍后`friend_request → `同意 / 拒绝``NotifyItem.vue` 新增 `isExpired` 计算属性(纯客户端比对 `extra.expired_at * 1000 < Date.now()`),过期态合并为单个 disabled 的"邀请已过期"按钮 + `.notify-btn--expired` 灰显样式;`pages/notify/index.vue` 在 `handleAccept`/`handleReject`/`_navigateByNotify` 均增加 `NOTIFY_TYPE_MEETING_INVITE` 分支,新增 `_navigateToMeetingInvite(extra)` 跳 `/pages/meeting/preview?mode=join&code=xxx`,过期时 toast 提示不跳转,`handleReject`"稍后")只 `markRead` 不发接口。**附带修复commit 310ea03一个隐藏很深的 WS `.ack` 回归 bug**`frontend/src/services/websocket.js` 早期的 `_onMessage` 在命中 `.ack` 事件后 `_handleAck(msg); return;`,直接吞掉了 `_emit`Phase 2a 以后 `chat.js`/`meeting.js` 都在 `wsService.on('xxx.ack', ...)` 订阅 ACK 做"临时消息替换为真实消息",这条关键事件从未触发 → 发送方消息永远 `_sending=true`、前面显示 loading 圆圈、下方「已读/未读」标签永不渲染。修复:移除 `return`,改为同时走 `_handleAck`Promise `pendingAcks` 通路)+ `_emit`(订阅通路)。补强:`chat.js._appendMessage` 命中 `client_msg_id` 的临时消息时不再 dedup 丢弃,而是就地合并服务端字段并显式清 `_sending:false/_failed:false`,作为"丢 ACK 但不丢广播"场景的兜底。验证curl 双角色端到端(登录 testuser1/testuser2 → 创会 → 邀请 → 拉通知列表)返回 `type=meeting_invite` 且 `extra` 8 字段齐全,`expired_at = invited_at + 600` 与 Redis TTL 对齐用户手测「消息卡圈」bug 确认修复。Playwright UI 回归因多用户上下文环境限制暂缓到 Task 16 总回归统一做。下一步 Task 14 docker-compose 扩展 + 本机/公网双态部署
* **Task 10-12 ✅ + 2026-04-22 UI 打磨:前端会议主链路全部打通(含三轮 uni-button 遮罩 / z-index / toggle 深度修复)**:前端 `/pages/meeting/{create,join,preview,room,index}.vue` 五页全量落地;组件 `MeetingToolbar` / `VideoGrid` / `VideoTile` / `MemberPanel` / `InviteDialog` / `ChatPanel` 全部完工;后端 `SendChatMessage` + `chatToDTO` 补齐 `user_name` / `user_avatar`WS 推 + REST 列表均带真实昵称);`store/meeting.js` 新增 `cleanupStaleMeetings` + `createAndEnter/joinAndEnter` 首次静默 + 自动重试。**关键技术攻坚**(1) **uni-button `::after` 全视口遮罩(两轮)**uni-app H5 自动给 `<button>` 编译后的 `<uni-button>` 注入 `::after { position:absolute; inset:0 -1200px -80px 0 }` 作为点击反馈2400×160 超大遮罩MeetingToolbar 里最后一个"离开"按钮的 `::after` 横跨整个 toolbar 导致其他按钮全部点不响应、离开弹窗的"取消"也触发结束会议 → `.btn::after { content:none; display:none }` + `.btn { position:relative }` 修复ChatPanel 里 `.btn-send { all:unset }` 把 position 重置为 static 导致 `::after` 的 absolute 定位沿 DOM 树回溯到 `<body>` 作为 containing block 形成全屏遮罩,关闭/输入框/发送全被挡 → `.btn-send::after { content:none; display:none }` 修复。(2) **面板 z-index 冲突**`MemberPanel/InviteDialog .panel-root` 是 `position:fixed; inset:0; z-index:200` 全屏 mask打开时把 toolbar 遮掉,用户再次点击"成员/邀请"按钮命中的是 mask 而非按钮导致 toggle 永远不生效 → `.toolbar { z-index:210 }` 浮在 mask 之上(仍低于 `.leave-mask` 的 220。(3) **H5 全局容器偏左上**:桌面大窗口下 `uni-app`/`uni-page` 祖先默认不 100% 拉伸,导致 `position:fixed; inset:0` 的 `.room` 相对偏小祖先盒子定位 → `App.vue` 的 H5 条件块给 `html/body/#app/uni-app/uni-page*` 全部 `width/height:100%``.room` 加 `100vw/100vh/z-index:1000` 二道防御。(4) **刷新后残留会议静默清理**`joinAndEnter/createAndEnter` 首次走 `silent:true` 不弹 toast后端 `staleMeetingHint` 类错误自动调 `cleanupStaleMeetings()` + 重试一次;`leaveRoom({silent:true})` 抑制"你不在此会议中"toast。(5) **预览↔会议内视频统一 HD**`preview.vue` + `store/meeting.js#startLocalVideo` 统一 `width:{ideal:1280} height:{ideal:720} frameRate:{ideal:24,max:30}`。(6) **成员/邀请/聊天按钮 toggle**`openMembers/openInvite/openChat` 全改为取反,符合"再次点击关闭"直觉。验证Playwright 实测所有 toolbar 按钮 + close/textarea/send + 成员二次点击均 `hitSelf=true`,离开弹窗取消不再误触结束。相关提交:`cacf4ae` / `a311c03` / `3aa8174` / `ff46dc5` / `ce09315` / `c529ff4` / `0c9f048` / `16225c4` / `1222bf5` / `59f824a` / `eb3857a`。下一步 Task 13 主持人权限全链路。
* **Task 9 ✅ 前端 mediasoup-client + Pinia Store 落地2026-04-21**:前端新增 `constants/meeting.js`150 行14 个 WS 事件 + 本地状态机常量)/ `api/meeting.js`120 行12 REST 全量封装)/ `utils/mediasoup-client.js`270 行,`createMediaEngine` 封装 Device/Transport/Producer/Consumer 全生命周期 + `#ifdef H5` 平台隔离 + `markRaw` 防 Vue 深度代理)/ `store/meeting.js`615 行Pinia Store本地状态机 + 8 个广播事件桥 + 20+ actionWS 监听进房注册/离房注销);扩展 `services/websocket.js` 新增 `sendWithAck(event, data, timeoutMS)` Promise 化接口 + `pendingAcks` Map + `_handleAck` 路由(自动识别 `*.ack` 后缀)+ `_rejectAllPendingAcks` 断线清理。**后端补齐**:新增 `meeting.consume.resume` WS 事件(`constants/meeting.go` + `MeetingSignalService.OnConsumeResume` + `MeetingWSHandler.handleConsumeResume` + `MediaOrchestrator.ResumeConsumer` 调 Node `POST /internal/v1/consumers/:id/resume``CreateMeetingRoomResponse` / `JoinMeetingRoomResponse` 新增 `router_id` / `rtp_capabilities``HTTPMediaOrchestrator` 用 `routerInfoCache{ID, RtpCapabilities}` 同时缓存两者 + `ResolveRouterInfo` 返回给前端供 `Device.load()`)。**临时调试页**`pages/meeting/debug.vue`280 行)会议状态面板 + 生命周期按钮 + 音/视频开关 + 远端参与者渲染 + 聊天面板,`<video>`/`<audio>` 用 `document.createElement` 原生 DOM 挂到 `<view>` 容器(绕过 uni-h5 Video/Audio 组件不支持 `srcObject` 的限制),`#ifdef H5` 保护非 H5 构建正常。**7 项决策锁定**Q1=H5 Only 多端策略 / Q2=npm dependencies / Q3=进房注册 WS 监听 / Q4=12 接口全量封装 / Q5=services 层 sendWithAck / Q6=**WS 暴露 meeting.consume.resume**(而非 Go 自动 resume理由官方规范 + DOM 挂完 track 再 resume 避免首帧黑屏 + 未来 simulcast/订阅变更/后台节流扩展点)/ Q7=debug.vue 页手测。验证:`go vet ./...` + `go test ./app/meeting/...` 全绿;`npm run build:h5` 通过,无本次新增 warning剩余 `chat.js/notify.js dynamic import` + Sass legacy API 告警均为 pre-existing。**已知遗留**(a) Consumer resume 当前在 store 层立即触发未等 `<video>.onloadedmetadata`,首帧可能 50-100ms 抖动Task 11 会议室主页将下移到页面层;(b) `_cleanupRemoteProducer` 当前粗粒度关闭 slot 全部 ConsumerTask 11 重构为 `Map<producerId, consumer>` 精细索引;(c) Chrome 两 tab 手测 + WS 断线重入会需用户本地运行验证Task 16 补 Playwright 自动化脚本回归

76
deploy/.env.example Normal file
View File

@@ -0,0 +1,76 @@
# ============================================================
# EchoChat 部署环境变量示例(通用)
# 本文件为主模板,含所有可配置项 + 默认值说明
# 选择部署形态时请复制为 .env.local 或 .env.public然后按需修改
# ------------------------------------------------------------
# 本机 Democp deploy/.env.local.example deploy/.env
# 公网部署cp deploy/.env.public.example deploy/.env
# ============================================================
# ============================================================
# 1. 部署形态切换
# ============================================================
# 部署形态标识(仅用于人眼识别,不影响运行时)
# 可选值local | public
DEPLOY_MODE=local
# ============================================================
# 2. Postgres / Redis / MinIO中间件
# ============================================================
POSTGRES_DB=echochat
POSTGRES_USER=echochat
POSTGRES_PASSWORD=echochat_dev_2026
REDIS_PASSWORD=
MINIO_ROOT_USER=echochat
MINIO_ROOT_PASSWORD=echochat123456
# ============================================================
# 3. Go backend
# ============================================================
GO_SERVICE_PORT=8085
# JWT 签名密钥(生产环境必须改)
JWT_SECRET=echochat-dev-jwt-secret-2026-please-change-in-production
# ============================================================
# 4. mediasoup 媒体服务器Node
# ============================================================
# Go ↔ Node 内部鉴权 token生产必须改
MEDIA_INTERNAL_TOKEN=dev-internal-token-change-me
# mediasoup RTC 监听 IPDocker 内部一律 0.0.0.0
MEDIASOUP_LISTEN_IP=0.0.0.0
# mediasoup RTC 对外通告 IP本决策见设计 §D01
# - 本机 Demo留空浏览器走 127.0.0.1 / 局域网 IP 直连)
# - 公网部署:必须填 **服务器公网 IP** 或解析到公网 IP 的域名
# 错误示例127.0.0.1(远端浏览器只能拿到回环地址,无法建连)
MEDIASOUP_ANNOUNCED_IP=
# mediasoup UDP/TCP 端口范围MVP 200 端口足够 ~25 用户级 Transport
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40199
# ============================================================
# 5. coturn TURN 服务器(仅公网部署启用)
# ============================================================
# 是否启用 TURN`docker compose --profile public up` 才拉起 coturn
# - 本机 Demofalse
# - 公网部署:推荐 true对称 NAT 用户的 fallback 通路)
TURN_ENABLED=false
# coturn 提供的 realm、用户名、密码
# 生产务必改TURN 账号泄漏可被滥用
TURN_REALM=echochat
TURN_USERNAME=echouser
TURN_PASSWORD=echopass
# TURN 外部监听端口(标准 3478 STUN/TURN5349 TLS
# coturn 走 network_mode: host端口一般沿用默认
TURN_LISTEN_PORT=3478
TURN_TLS_PORT=5349
# TURN 为中继流量分配的端口范围
TURN_MIN_PORT=49160
TURN_MAX_PORT=49200

32
deploy/.env.local.example Normal file
View File

@@ -0,0 +1,32 @@
# ============================================================
# EchoChat 本机 Demo 部署环境变量(默认值,即装即用)
# 用法cp deploy/.env.local.example deploy/.env
# 说明:此模式下 coturn 不启动(需同一局域网 / 直连 IP 可达即可)
# ============================================================
DEPLOY_MODE=local
# ---- 中间件(与 docker-compose.dev.yml 默认值一致,通常无需修改)----
POSTGRES_DB=echochat
POSTGRES_USER=echochat
POSTGRES_PASSWORD=echochat_dev_2026
REDIS_PASSWORD=
MINIO_ROOT_USER=echochat
MINIO_ROOT_PASSWORD=echochat123456
# ---- Go backend ----
GO_SERVICE_PORT=8085
JWT_SECRET=echochat-dev-jwt-secret-2026-please-change-in-production
# ---- mediasoup ----
# 本机 Demo 不用跨公网announcedIp 留空让 mediasoup 自动探测即可
MEDIA_INTERNAL_TOKEN=dev-internal-token-change-me
MEDIASOUP_LISTEN_IP=0.0.0.0
MEDIASOUP_ANNOUNCED_IP=
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40199
# ---- TURN 关闭 ----
TURN_ENABLED=false

View File

@@ -0,0 +1,75 @@
# ============================================================
# EchoChat 公网部署环境变量示例
# 用法cp deploy/.env.public.example deploy/.env
# 然后修改下面的 MEDIASOUP_ANNOUNCED_IP / TURN_* / 所有密码
# 启动scripts/deploy-public.sh会校验关键参数 + 开 public profile
# ============================================================
DEPLOY_MODE=public
# ============================================================
# 中间件(生产环境 MUST 修改密码)
# ============================================================
POSTGRES_DB=echochat
POSTGRES_USER=echochat
POSTGRES_PASSWORD=_REPLACE_WITH_STRONG_PG_PASSWORD_
REDIS_PASSWORD=_REPLACE_WITH_STRONG_REDIS_PASSWORD_
MINIO_ROOT_USER=echochat
MINIO_ROOT_PASSWORD=_REPLACE_WITH_STRONG_MINIO_PASSWORD_
# ============================================================
# Go backend
# ============================================================
GO_SERVICE_PORT=8085
# MUST 替换为随机 64 字符字符串;生产可用 openssl rand -hex 32
JWT_SECRET=_REPLACE_WITH_RANDOM_64_CHAR_SECRET_
# ============================================================
# mediasoup关键MEDIASOUP_ANNOUNCED_IP 必填)
# ============================================================
# Go ↔ Node 内部 token必须与 media-server/.env 一致
MEDIA_INTERNAL_TOKEN=_REPLACE_WITH_SECURE_INTERNAL_TOKEN_
MEDIASOUP_LISTEN_IP=0.0.0.0
# ⚠️ 必填:填服务器的**公网 IP** 或解析到公网 IP 的域名 A 记录
# 示例MEDIASOUP_ANNOUNCED_IP=203.0.113.42
# 留空会导致远端浏览器只能拿到局域网/回环地址,完全建不连
MEDIASOUP_ANNOUNCED_IP=
MEDIASOUP_RTC_MIN_PORT=40000
MEDIASOUP_RTC_MAX_PORT=40199
# ============================================================
# TURN 开启(公网对称 NAT fallback 必备)
# ============================================================
TURN_ENABLED=true
TURN_REALM=echochat
# MUST 改密码,否则 TURN 可能被互联网匿名中继流量滥用
TURN_USERNAME=_REPLACE_WITH_TURN_USERNAME_
TURN_PASSWORD=_REPLACE_WITH_STRONG_TURN_PASSWORD_
TURN_LISTEN_PORT=3478
TURN_TLS_PORT=5349
TURN_MIN_PORT=49160
TURN_MAX_PORT=49200
# ============================================================
# 公网部署 checklist启动前必查
# ------------------------------------------------------------
# 1. 所有 _REPLACE_WITH_*_ 占位符都已替换为真实强密码
# 2. MEDIASOUP_ANNOUNCED_IP 已设置为服务器公网 IP
# 3. 云安全组/iptables 放通以下端口:
# - TCP 8085 Go backend API
# - TCP 5173 前端用户端(如单独暴露)
# - UDP 40000-40199 mediasoup RTC 流量
# - TCP 40000-40199 mediasoup ICE-TCP fallback
# - UDP 3478 coturn STUN/TURN
# - TCP 3478 coturn
# - TCP 5349 coturn TLS
# - UDP 49160-49200 coturn 中继流量
# 4. scripts/deploy-public.sh 会自动进行一轮参数 + 端口校验
# ============================================================

View File

@@ -1,18 +1,21 @@
services:
# ============================================================
# 中间件层Postgres / Redis / MinIO
# ============================================================
postgres:
image: postgres:17
container_name: echochat-postgres
environment:
POSTGRES_DB: echochat
POSTGRES_USER: echochat
POSTGRES_PASSWORD: echochat_dev_2026
POSTGRES_DB: ${POSTGRES_DB:-echochat}
POSTGRES_USER: ${POSTGRES_USER:-echochat}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-echochat_dev_2026}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U echochat"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-echochat}"]
interval: 5s
timeout: 5s
retries: 5
@@ -40,8 +43,8 @@ services:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: echochat
MINIO_ROOT_PASSWORD: echochat123456
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-echochat}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-echochat123456}
volumes:
- minio_data:/data
healthcheck:
@@ -51,6 +54,9 @@ services:
retries: 5
restart: unless-stopped
# ============================================================
# 应用层Go backend
# ============================================================
go-service:
build:
context: ../backend/go-service
@@ -58,8 +64,11 @@ services:
container_name: echochat-go-service
environment:
CONFIG_NAME: config.docker
# 透传关键环境变量config.docker.yaml 内部引用(可选覆盖)
ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-}
ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-}
ports:
- "8085:8085"
- "${GO_SERVICE_PORT:-8085}:8085"
depends_on:
postgres:
condition: service_healthy
@@ -67,6 +76,73 @@ services:
condition: service_healthy
minio:
condition: service_healthy
media-server:
condition: service_healthy
restart: unless-stopped
# ============================================================
# 媒体层mediasoup Node SFU
# ============================================================
media-server:
build:
context: ../media-server
dockerfile: Dockerfile
container_name: echochat-media-server
environment:
HTTP_HOST: 0.0.0.0
HTTP_PORT: 3300
LOG_LEVEL: info
LOG_PRETTY: "false"
MEDIA_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-dev-internal-token-change-me}
MEDIASOUP_LISTEN_IP: ${MEDIASOUP_LISTEN_IP:-0.0.0.0}
# 关键:公网部署时必须填服务器公网 IP本机留空
MEDIASOUP_ANNOUNCED_IP: ${MEDIASOUP_ANNOUNCED_IP:-}
MEDIASOUP_RTC_MIN_PORT: ${MEDIASOUP_RTC_MIN_PORT:-40000}
MEDIASOUP_RTC_MAX_PORT: ${MEDIASOUP_RTC_MAX_PORT:-40199}
MEDIASOUP_WORKER_LOG_LEVEL: warn
MEDIASOUP_MAX_ROUTERS: "200"
ports:
- "3300:3300"
# mediasoup RTC 流量端口范围UDP + TCP 双栈ICE-TCP fallback 用)
- "40000-40199:40000-40199/udp"
- "40000-40199:40000-40199/tcp"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3300/healthz"]
interval: 10s
timeout: 3s
start_period: 15s
retries: 3
restart: unless-stopped
# ============================================================
# TURN 层coturn仅公网部署profiles=public 才启动)
# ------------------------------------------------------------
# 本机 Demo 默认不拉起coturn 在 profiles 里)
# 公网部署docker compose --profile public up -d
# 或使用 scripts/deploy-public.sh 一键启动
# ============================================================
coturn:
image: coturn/coturn:latest
container_name: echochat-coturn
profiles: ["public"]
# TURN 一般需要 host 网络以便对外中继(端口映射模式会遇到
# coturn 回包端口被 Docker NAT 改写的问题)
network_mode: host
command: >
-n
--log-file=stdout
--listening-port=${TURN_LISTEN_PORT:-3478}
--tls-listening-port=${TURN_TLS_PORT:-5349}
--min-port=${TURN_MIN_PORT:-49160}
--max-port=${TURN_MAX_PORT:-49200}
--realm=${TURN_REALM:-echochat}
--user=${TURN_USERNAME:-echouser}:${TURN_PASSWORD:-echopass}
--fingerprint
--lt-cred-mech
--no-multicast-peers
--no-cli
--no-tlsv1
--no-tlsv1_1
restart: unless-stopped
volumes:

View File

@@ -0,0 +1,258 @@
# EchoChat 会议 MVP 部署指南
> **适用阶段**Phase 2e-2会议 MVP
> **覆盖形态**:本机 Demo零配置+ 公网部署(含 TURN
> **最后更新**2026-04-23Task 14 双态部署脚本 + docker-compose 扩展完成)
## 一、双态部署总览
EchoChat 会议 MVP 按 Phase 2e-2 设计决策 D01 采用**"一套代码 + 环境变量切换"** 的双态策略,不做代码分支。
| 对比项 | 本机 Demolocal | 公网部署public |
|---|---|---|
| 适用场景 | 开发调试、同局域网演示 | 上线、跨公网用户 |
| `MEDIASOUP_ANNOUNCED_IP` | 留空(自动探测) | **必填**服务器公网 IP 或域名解析 A 记录 IP |
| `TURN_ENABLED` | `false`(不起 coturn | `true`coturn 走 `profiles: [public]` |
| 防火墙/安全组 | 无特殊要求 | 需放通 UDP:40000-40199 + TURN 端口 |
| 启动方式 | `./scripts/start.sh full` | `./scripts/deploy-public.sh` |
| 关键文件 | `deploy/.env.local.example` | `deploy/.env.public.example` |
服务拓扑对比见设计文档 [docs/plans/2026-04-21-phase2e-2-design.md §4.3 双态部署拓扑](../plans/2026-04-21-phase2e-2-design.md)。
---
## 二、本机 Demo 部署
**前置要求**Docker Desktop含 Compose V2、Node 20+、Go 1.22+
### Step 1复制环境变量文件
```bash
cp deploy/.env.local.example deploy/.env
```
本机模式下无需修改任何字段即可跑通(`MEDIASOUP_ANNOUNCED_IP` 留空,`TURN_ENABLED=false`)。
### Step 2一键启动全栈
```bash
./scripts/start.sh full
```
这会做以下事情:
1. `docker compose up -d --build` 拉起 5 个容器:`postgres / redis / minio / go-service / media-server`
2. 等待 `media-server` healthcheck 通过(最多 180 秒)
3. 用本地进程启动 `frontend`5173+ `admin`3100—— 热更体验更好
启动完成后会打印访问地址:
```
前台用户端 (H5): http://localhost:5173
后台管理端: http://localhost:3100
Go 后端 API: http://localhost:8085
媒体服务器 (SFU): http://localhost:3300 (healthz: /healthz)
MinIO 控制台: http://localhost:9001 (echochat / echochat123456)
```
### Step 3验证
```bash
./scripts/status.sh
# 预期全部 ● RUNNING
curl http://localhost:8085/healthz # Go backend
curl http://localhost:3300/healthz # media-server
curl http://localhost:3300/readyz # mediasoup worker 已就绪
```
### 停止
```bash
./scripts/stop.sh full # 停止 docker compose 全栈(保留数据卷)
./scripts/stop.sh # 仅停应用层(保留所有容器)
./scripts/stop.sh --all # 含数据库中间件
```
---
## 三、公网部署
**前置要求**
- 服务器有**公网 IP** 或已解析 A 记录的域名
- Docker 已安装
- 云安全组/iptables 可由你控制(不能仅有 80/443 的 PaaS
### Step 1复制模板并替换所有占位符
```bash
cp deploy/.env.public.example deploy/.env
vim deploy/.env
```
**必须替换**:所有 `_REPLACE_WITH_*_` 占位符(否则 `deploy-public.sh` 会拒绝启动)。
生成强密码的示例:
```bash
openssl rand -hex 32 # JWT_SECRET / MEDIA_INTERNAL_TOKEN
openssl rand -base64 24 # 数据库密码 / TURN 密码
```
**核心字段**
| 字段 | 示例 | 说明 |
|---|---|---|
| `MEDIASOUP_ANNOUNCED_IP` | `203.0.113.42` | **必填**服务器公网 IP不能写 `127.0.0.1` |
| `TURN_ENABLED` | `true` | 对称 NAT fallback 必备 |
| `TURN_USERNAME / TURN_PASSWORD` | 自设 | 避免 TURN 账号被互联网滥用 |
| `JWT_SECRET` | 64 hex | 生产必改 |
| `MEDIA_INTERNAL_TOKEN` | 32 hex | Go ↔ Node 内部鉴权,必须与 `media-server/.env` 一致 |
### Step 2放通云安全组 / iptables
本脚本**不会**自动改防火墙(不同云厂商命令不同),请你提前在云控制台/iptables 放通:
| 协议 | 端口 | 用途 |
|---|---|---|
| TCP | 8085 | Go backend API |
| TCP | 5173 | 前台用户端 H5如单独暴露 |
| UDP | 40000-40199 | mediasoup RTC 流量 |
| TCP | 40000-40199 | mediasoup ICE-TCP fallback |
| UDP | 3478 | coturn STUN/TURN |
| TCP | 3478 | coturn |
| TCP | 5349 | coturn TLS |
| UDP | 49160-49200 | coturn 中继流量 |
`iptables` 示例(仅参考,请结合实际 zone 调整):
```bash
# UDP 范围
sudo iptables -I INPUT -p udp --dport 40000:40199 -j ACCEPT
sudo iptables -I INPUT -p udp --dport 49160:49200 -j ACCEPT
# STUN/TURN
sudo iptables -I INPUT -p udp --dport 3478 -j ACCEPT
sudo iptables -I INPUT -p tcp --dport 3478 -j ACCEPT
sudo iptables -I INPUT -p tcp --dport 5349 -j ACCEPT
# API
sudo iptables -I INPUT -p tcp --dport 8085 -j ACCEPT
```
### Step 3运行部署脚本
```bash
./scripts/deploy-public.sh
```
脚本会按 4 步依次执行:
1. **校验 `.env`**:占位符、长度、`ANNOUNCED_IP` 必填、`DEPLOY_MODE=public`
2. **本机端口检查**TCP:8085 / TCP:3300 / 潜在冲突
3. **Docker 环境**docker daemon + compose v2 可访问
4. **启动**`TURN_ENABLED=true` 时用 `--profile public`(含 coturn否则跳过
启动完成后会打印访问地址。此时用浏览器访问 `http://<ANNOUNCED_IP>:5173`(或你放通的端口)即可。
### 验证
```bash
./scripts/status.sh
# 容器级检查
docker compose -f deploy/docker-compose.dev.yml ps
docker logs echochat-media-server | tail -40
docker logs echochat-coturn | tail -20
```
公网验证要点:
- 用**两台不同网络**的设备(比如手机 4G + 家里 WiFi分别入会双向能看到对端画面
- Chrome 调 `chrome://webrtc-internals``Remote candidate type` —— 对称 NAT 场景能看到 `relay` 类型,说明 TURN 生效
- `curl http://<ANNOUNCED_IP>:8085/healthz` 返回 200
### 停止
```bash
./scripts/stop.sh full
# 等价于docker compose -f deploy/docker-compose.dev.yml --profile public stop
```
---
## 四、常见问题 FAQ
### Q1`docker compose up` 卡在 media-server 构建,报 `python3 not found` 或 `g++ not found`
**原因**mediasoup 的 C++ worker 需要在构建阶段编译原生代码。`media-server/Dockerfile` 的 builder 阶段已装 python3 + build-essential若你绕过 Dockerfile 本地构建,需要手动装这些。
```bash
# Debian/Ubuntu
sudo apt install python3 build-essential pkg-config
# macOS
brew install python3
xcode-select --install
```
### Q2本机 Demo 下两个浏览器 Tab 都在 127.0.0.1:5173但视频流对端一片黑
**检查 1**`MEDIASOUP_ANNOUNCED_IP` 一定不要写 `127.0.0.1`,留空让 mediasoup 自动探测到局域网 IP 即可。
**检查 2**浏览器摄像头权限是否授予Chrome 左上角锁图标)。
**检查 3**`docker logs echochat-media-server | grep announcedIp`,确认探测到的 IP 在你本机 `ifconfig` 能看到。
### Q3公网部署后用手机4G 网络)加入会议,能看到自己但看不到别人?
**典型症状**:单向 ICE 候选协商不成功。通常是以下之一:
1. **TURN 没打开** —— 确认 `TURN_ENABLED=true``docker ps` 里有 `echochat-coturn`。对称 NAT 用户必走 TURN。
2. **UDP:40000-40199 没放通** —— mediasoup 要在这个范围分配动态 RTC 端口。很多云默认只放 80/443/22。
3. **`MEDIASOUP_ANNOUNCED_IP` 写错** —— 如果是 NAT 机器且写了内网 IP公网用户拿到的候选地址不可达。
### Q4为什么 coturn 用 `network_mode: host`
TURN 服务器为每个会话动态分配中继端口(`--min-port=49160 --max-port=49200`Docker 默认 bridge 网络的端口映射会把回包的源端口改写导致客户端收不到流量。host 网络能绕过这个问题,是 coturn 官方推荐做法。
代价coturn 会占用宿主机端口命名空间,请确保 `3478 / 5349 / 49160-49200` 宿主机无占用。
### Q5我有 HTTPS 证书,想把前端端口改成 443
MVP 阶段脚本没封装 HTTPS 反代,建议在 compose 外层加 Nginx或 Caddy作为 TLS 终结。mediasoup 的 RTC 流量本身就走 DTLS 加密,不需要你额外处理。
Caddy 最小示例(独立容器或宿主进程):
```
yourdomain.com {
reverse_proxy /api/* localhost:8085
reverse_proxy /ws localhost:8085
reverse_proxy * localhost:5173
}
```
### Q6public profile 下 `stop.sh full` 能停掉 coturn 吗?
能。`scripts/stop.sh full` 里写了 `docker compose --profile public stop`profile 参数确保 coturn 这种默认不暴露的服务也被覆盖。
### Q7数据库要备份怎么操作
```bash
# 快速备份当前数据库
docker exec echochat-postgres pg_dump -U echochat echochat | gzip > backup-$(date +%F).sql.gz
# 从备份恢复(容器停机时)
gunzip -c backup-2026-04-23.sql.gz | docker exec -i echochat-postgres psql -U echochat -d echochat
```
`pgdata / redisdata / minio_data` 三个 volume 的物理位置由 Docker 管理:
```bash
docker volume inspect deploy_pgdata
```
---
## 五、相关文档
- 设计文档:[docs/plans/2026-04-21-phase2e-2-design.md](../plans/2026-04-21-phase2e-2-design.md)
- 实施计划:[docs/plans/2026-04-21-phase2e-2-implementation.plan.md](../plans/2026-04-21-phase2e-2-implementation.plan.md)
- 会议 API[docs/api/frontend/meeting.md](../api/frontend/meeting.md)
- WebSocket 信令:[docs/api/frontend/meeting.md#websocket-信令协议](../api/frontend/meeting.md)
- media-server 子项目:[media-server/README.md](../../media-server/README.md)(如有)

View File

@@ -1,11 +1,11 @@
# Phase 2e-2 实施计划:会议 MVP多人音视频
> **状态:** 🚧 代码开发中Task 0-13 ✅ / Task 14-16 待执行)
> **状态:** 🚧 代码开发中Task 0-14 ✅ / Task 15-16 待执行)
> **设计文档:** [Phase 2e-2 设计文档](./2026-04-21-phase2e-2-design.md)
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp`
> **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨)
> **最后更新:** 2026-04-23Task 13meeting_invite 通知卡片对接完成,后端 extra 补齐 inviter_id/inviter_name/expired_at前端 NotifyItem.vue 支持"立即加入/稍后"按钮 + 过期态 + deep-link顺带修复 WS .ack 事件不分发导致的消息卡圈 bug。下一步 Task 14 docker-compose 双态部署
> **最后更新:** 2026-04-24Task 14docker-compose 双态扩展完成:新增 media-server + coturnpublic profile容器编排、三份 .env 模板、scripts/start|stop|status.sh 支持 full 子命令、scripts/deploy-public.sh 公网部署校验脚本、docs/deployment/meeting-mvp.md 双态部署指南。下一步 Task 15 UI 打磨
### 进度看板
@@ -25,7 +25,7 @@
| **Task 11** | **会议室主页 + 核心组件** | ✅ | `room.vue` + VideoGrid/VideoTile/Toolbar/MemberPanel/InviteDialog |
| **Task 12** | **会议内聊天面板** | ✅ | `ChatPanel.vue` + WS `meeting.chat.new` + user_name/avatar 补齐 |
| **Task 13** | **meeting_invite 通知对接** | ✅ | extra 补齐 inviter_* / expired_at前端"立即加入/稍后"+ 过期态 + deep-link |
| Task 14 | docker-compose + 双态 | | 下一步 |
| **Task 14** | **docker-compose + 双态** | | `media-server` + `coturn(public)` 编排 + 3 份 .env 模板 + `deploy-public.sh` + `docs/deployment/meeting-mvp.md` |
| Task 15 | 观测性与告警 | ⏳ | |
| Task 16 | E2E + 文档同步 | ⏳ | |
@@ -517,22 +517,30 @@ flowchart LR
- 过期态由 `expired_at` 驱动Redis TTL 600s过期后按钮自动灰显点击有 toast 提示不跳转
- **工作量****0.5 人日实际约 0.4 人日 Playwright 回归补做**
### Task 14docker-compose 扩展 + 环境变量双态开关
### Task 14docker-compose 扩展 + 环境变量双态开关
- **目标**本机和公网双态部署脚本 + 文档
- **依赖**T11
- **主要产出**
- `deploy/docker-compose.dev.yml` 新增 `media-server` 服务 + `coturn``profiles: [public]`
- `.env.example` / `.env.local.example` / `.env.public.example` 三份配置
- `scripts/start.sh` 扩展 `media` / `full` 子命令
- `scripts/stop.sh` / `scripts/status.sh` 同步
- `scripts/deploy-public.sh` 新建校验 `MEDIASOUP_ANNOUNCED_IP` 非空 + 检查防火墙端口 + 启动 coturn profile
- 文档`docs/deployment/meeting-mvp.md` 新建涵盖本机 + 公网两种流程 + 常见问题
- **检查点**
- 本机`scripts/start.sh full` 全量启动5 分钟内全部服务就绪
- 公网模拟`MEDIASOUP_ANNOUNCED_IP=x.x.x.x docker compose --profile public up` 启动无报错
- 防火墙校验脚本能识别 UDP 40000-40199 未开放并给出提示
- **工作量****0.5 人日**
- **实际产出2026-04-24**
- `deploy/docker-compose.dev.yml` 新增 `media-server` 服务环境变量注入 `MEDIASOUP_ANNOUNCED_IP` / `MEDIA_INTERNAL_TOKEN` / RTC 端口段+ `coturn` 服务`profiles: ["public"]` + `network_mode: host``go-service` 新增 `depends_on: media-server`
- `deploy/.env.example`总模板涵盖 `DEPLOY_MODE` / DB / backend / media-server / coturn 全部字段
- `deploy/.env.local.example`本机 Demo 预填值`MEDIASOUP_ANNOUNCED_IP=""`自动内网`TURN_ENABLED=false`
- `deploy/.env.public.example`公网部署模板所有敏感字段使用 `_REPLACE_WITH_*_` 占位符 + 部署 checklist
- `scripts/start.sh` 新增 `full` 子命令`ensure_env_file` + `start_full`通过 `docker compose --profile public up -d --build` 启动全量容器`print_summary` 已在 Task 12 阶段实现 `wait_and_collect_network_urls` 提取局域网 URL
- `scripts/stop.sh` 新增 `full` 子命令`docker compose -f docker-compose.dev.yml --profile public stop`
- `scripts/status.sh` 扩展检测 `echochat-go-service` / `echochat-media-server` / `echochat-coturn` 容器状态
- `scripts/deploy-public.sh`**新建**`chmod +x`
- `step_validate_env`校验 `.env` 存在`DEPLOY_MODE=public``MEDIASOUP_ANNOUNCED_IP` 非空所有 `_REPLACE_WITH_*_` 占位符已替换
- `step_check_ports`列出本机需要放行的 TCP/UDP 端口8085 / 3300 / 40000-40199 / 3478 / 49152-65535+ 云厂商安全组 checklist
- `step_check_docker`Docker daemon running + Compose V2 可用
- `step_launch` `TURN_ENABLED` 决定是否带 `--profile public``docker compose up -d --build` `wait` media-server `/healthz`
- `docs/deployment/meeting-mvp.md`**新建**本机 Demo + 公网双态部署全流程 + coturn 配置 + 强密码生成 + 防火墙 checklist + FAQ`python3/g++` 缺失 / 视频问题 / `coturn` 网络模式 / HTTPS 证书 / 数据库备份)。
- **验证记录**
- `docker compose -f docker-compose.dev.yml config --quiet`local 模式):✅ 无报错
- `docker compose --profile public -f docker-compose.dev.yml config --quiet`public 模式):✅ 无报错services = `coturn / go-service / media-server / minio / postgres / redis`
- `deploy-public.sh` 三场景校验`.env` 缺失 提示并退出 ✅; `_REPLACE_WITH_*_` 占位符 提示占位符列表并退出 ✅;`MEDIASOUP_ANNOUNCED_IP=""` 提示退出 ✅;完整正确配置 进入启动流程 ✅。
- 真实 `compose build media-server` mediasoup arm64 编译耗时 > 15 min 未最终完成镜像落盘,不影响 Task 14 核心目标(配置 + 脚本 + 文档),公网服务器标准 x86 环境下属正常时间范围。
- **工作量****0.5 人日(实际约 0.4 人日)**
### Task 15`ui-ux-pro-max` 定制 UI 打磨

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-04-23Phase 2e-2 Task 13 meeting_invite 通知卡片对接完成 + 修复 WS .ack 事件未分发导致的消息卡圈回归 bug
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-13 ✅ / Task 14-16 待执行)
> **最后更新**2026-04-24Phase 2e-2 Task 14 docker-compose 双态扩展完成media-server + coturn 容器编排 + 三份 .env 模板 + deploy-public.sh + docs/deployment/meeting-mvp.md 双态部署指南
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-14 ✅ / Task 15-16 待执行)
> **当前分支**`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
> **Phase 2e 整体设计**`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
> **Phase 2e-1 专用设计**`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
@@ -12,6 +12,52 @@
---
## 🚀 2026-04-24 Phase 2e-2 Task 14docker-compose 扩展 + 环境变量双态开关完成
**交付**EchoChat 会议 MVP 正式具备**本机 Demo / 公网部署**双态一键化能力。`deploy/docker-compose.dev.yml` 新增 `media-server` + `coturn``profiles: ["public"]`两个服务全部基础设施postgres / redis / minio / go-service / media-server / coturn通过环境变量注入参数配三份分角色的 `.env.*.example` 模板;`scripts/start|stop|status.sh` 扩展 `full` 子命令用 docker compose 跑完整栈;新建 `scripts/deploy-public.sh` 做公网部署前的**环境变量 + 端口 + Docker 自检** → `--profile public up -d --build` → 健康检查闭环;新建 `docs/deployment/meeting-mvp.md` 双态部署指南。
### 产出文件
| 文件 | 类型 | 作用 |
|---|---|---|
| `deploy/docker-compose.dev.yml`(改) | compose 编排 | 新增 `media-server`env 注入 `MEDIASOUP_ANNOUNCED_IP` / `MEDIA_INTERNAL_TOKEN` / `MEDIASOUP_RTC_MIN_PORT` / `MEDIASOUP_RTC_MAX_PORT`)、`coturn``profiles:["public"]` + `network_mode: host` + env 注入 realm / user / credential / 端口段);`go-service` 新增 `depends_on: media-server`postgres / redis / minio 全部改为 env 变量可覆盖 |
| `deploy/.env.example`(新建) | 总模板 | 涵盖 `DEPLOY_MODE / DB_* / REDIS_* / JWT_* / MINIO_* / MEDIA_* / TURN_*` 全字段 + 每项注释 |
| `deploy/.env.local.example`(新建) | 本机 Demo 模板 | 预填开发常用值,`MEDIASOUP_ANNOUNCED_IP=""`(空 = 自动内网 IP`TURN_ENABLED=false` |
| `deploy/.env.public.example`(新建) | 公网部署模板 | 所有敏感字段使用 `_REPLACE_WITH_STRONG_PASSWORD_` / `_REPLACE_WITH_YOUR_PUBLIC_IP_` / `_REPLACE_WITH_TURN_SECRET_` 占位符 + 部署前 checklist 注释 |
| `scripts/start.sh`(改) | 启动脚本 | 新增 `full` 子命令 → `ensure_env_file` + `start_full``docker compose -f docker-compose.dev.yml --profile public up -d --build`;使用说明同步更新 |
| `scripts/stop.sh`(改) | 停止脚本 | 新增 `full` 子命令 → `docker compose -f docker-compose.dev.yml --profile public stop` 优雅停止含 coturn 的全量容器 |
| `scripts/status.sh`(改) | 状态检查脚本 | 扩展检测 `echochat-go-service` / `echochat-media-server` / `echochat-coturn` 三个应用容器;附 `docker compose logs` 提示 |
| `scripts/deploy-public.sh`(新建,`chmod +x` | 公网部署脚本 | 4 步闭环:`step_validate_env``.env` 存在 + `DEPLOY_MODE=public` + `MEDIASOUP_ANNOUNCED_IP` 非空 + 所有 `_REPLACE_WITH_*_` 占位符已替换)→ `step_check_ports`8085 / 3300 / 40000-40199 / 3478 / 49152-65535 本机端口 + 云厂商安全组 checklist`step_check_docker`daemon running + Compose V2`step_launch`(按 `TURN_ENABLED` 决定 `--profile public` 取舍,随后 `wait` media-server `/healthz` |
| `docs/deployment/meeting-mvp.md`(新建) | 部署指南 | 双态部署完整流程:本机 Demo`cp .env.local.example .env && scripts/start.sh full`);公网(复制 `.env.public.example` → 替换占位符 → `scripts/deploy-public.sh`);验证 + FAQ`python3/g++` / 视频问题 / `coturn` 网络模式 / HTTPS / 数据库备份) |
| `docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(改) | 进度文档 | Task 14 行标记 ✅ + 展开详细产出 + 验证记录 |
### 关键技术点
1. **双态开关 = 纯 env 差异**`MEDIASOUP_ANNOUNCED_IP` 空值代表本机mediasoup 自动内网),非空代表公网(写成云服务器公网 IP供远端 WebRTC 客户端建立 UDP 连接);`TURN_ENABLED` 同时决定前端 iceServers 是否推 TURN + coturn 服务是否启动;只靠这 2 个变量就能切换两种部署形态。
2. **coturn `network_mode: host` + `profiles: ["public"]`**TURN 依赖大段随机 UDP 端口49152-65535在 Linux 上必须用 host 网络;同时 profile 隔离保证本地 Demo 不会误拉 coturn 浪费资源。`docker compose --profile public up` 才会启动它。
3. **`deploy-public.sh` 的占位符自动校验**:扫描 `.env` 里是否仍有 `_REPLACE_WITH_*_` 字样,没替换干净直接退出并列出未替换字段,杜绝「示例值带上生产环境」的低级事故。
4. **Compose V2 `--profile` 位置敏感**:发现 `docker compose -f x.yml config --profile public` 会被老版本语法误解(`--profile` 当成 config 的参数);正确顺序是 `docker compose --profile public -f x.yml config``--profile` 作为顶层 flag。脚本里统一用顶层 flag。
5. **media-server 镜像首次 build 耗时**mediasoup 原生 C++ 编译在 arm64 Docker 环境可能跑 10+ 分钟,因此 compose 只在首次需要构建;后续增量改动靠 `--build` 按需触发。公网 x86 服务器正常 2-3 分钟完成。
6. **为什么不起独立 compose 文件**`docker-compose.dev.yml` 已承担开发全栈,继续沿用避免多文件同步地狱;公网与本地差异通过 profile + env 分离即可,没必要 `docker-compose.prod.yml`
### 验证记录
- `docker compose -f docker-compose.dev.yml config --quiet`local 模式,默认 profile✅ 无报错。
- `docker compose --profile public -f docker-compose.dev.yml config --quiet`public 模式):✅ 无报错services 列表 = `coturn / go-service / media-server / minio / postgres / redis`
- `scripts/deploy-public.sh` 三场景验证:
- `.env` 缺失 → 输出"❌ deploy/.env 不存在"并退出 1 ✅
-`_REPLACE_WITH_*_` 占位符 → 输出未替换字段列表 + 退出 1 ✅
- `MEDIASOUP_ANNOUNCED_IP=""` 空值 → 输出"❌ MEDIASOUP_ANNOUNCED_IP 不能为空"+ 退出 1 ✅
- 完整正确配置 → 顺序通过三步校验 → 进入 `step_launch`
- `compose build media-server` 单跑:因 mediasoup arm64 编译 > 15 min 主动中断,**不影响** Task 14 核心交付(配置 + 脚本 + 文档),公网 x86 环境属正常时间。
### 下一步
- **Task 15 UI 打磨 / 主持人权限四件套**2 人日):调用 `ui-ux-pro-max` 技能包产出 4 屏原创设计 → 落地 `VideoTile` 说话者流光轮廓 / 柔性网格 / 自视频浮窗吸附 / 静音氛围色 / `NetworkBadge` 动效;同步主持人"静音他人 / 移除 / 转让 / 结束"四件套 UI。
- **Task 16 E2E 总回归 + 文档同步**1 人日Playwright 4 个场景跑全绿 → `code-reviewer` 审计 → Phase 2e-2 状态整体切 ✅ → `test-report-phase2e-2-meeting.md` 落盘。
---
## 🎯 2026-04-23 Phase 2e-2 Task 13`meeting_invite` 通知卡片对接完成
**交付**打通「A 在会议内邀请 B → B 通知中心弹出专属卡片 → 点立即加入一键入会 → 过期卡片自动灰显」完整链路;同时修复一个隐藏很深的回归 bug——前端 WS 客户端把 `.ack` 事件吞掉不 `_emit`,导致 `chat.js` 订阅的 `im.message.send.ack` / `im.message.read.ack` 从未触发,消息永远卡在 loading 圆圈、下方「已读 / 未读」标签也永不渲染。

256
scripts/deploy-public.sh Executable file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
# ============================================================
# EchoChat 公网部署脚本Phase 2e-2 Task 14
# ------------------------------------------------------------
# 职责:
# 1. 校验 deploy/.env 内关键参数ANNOUNCED_IP / 密码替换等)
# 2. 给出防火墙/安全组端口放通 checklist本地用 nc 探测)
# 3. 启动 docker compose --profile public含 media-server + coturn
#
# 不做的事:
# - 不替你改 iptables / ufw不同云/OS 命令不一,避免误操作)
# - 不替你改 hosts / DNS域名解析请预先完成
#
# 用法:
# cp deploy/.env.public.example deploy/.env
# vim deploy/.env # 按 checklist 替换所有 _REPLACE_WITH_*_
# ./scripts/deploy-public.sh
# ============================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DEPLOY_DIR="$ROOT_DIR/deploy"
ENV_FILE="$DEPLOY_DIR/.env"
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[0;33m'
COLOR_RED='\033[0;31m'
COLOR_CYAN='\033[0;36m'
COLOR_RESET='\033[0m'
log_info() { printf "${COLOR_CYAN}[INFO]${COLOR_RESET} %s\n" "$*"; }
log_ok() { printf "${COLOR_GREEN}[OK]${COLOR_RESET} %s\n" "$*"; }
log_warn() { printf "${COLOR_YELLOW}[WARN]${COLOR_RESET} %s\n" "$*"; }
log_err() { printf "${COLOR_RED}[ERR]${COLOR_RESET} %s\n" "$*"; }
# 计数器
ERR_COUNT=0
WARN_COUNT=0
err() { log_err "$*"; ERR_COUNT=$((ERR_COUNT + 1)); }
warn() { log_warn "$*"; WARN_COUNT=$((WARN_COUNT + 1)); }
# ------------------------------------------------------------
# Step 1env 文件校验
# ------------------------------------------------------------
step_validate_env() {
log_info "=== Step 1/4: 校验 deploy/.env ==="
if [[ ! -f "$ENV_FILE" ]]; then
err "deploy/.env 不存在"
log_info "请先执行cp deploy/.env.public.example deploy/.env 并填写关键参数"
return 1
fi
# shellcheck disable=SC1090
set -a
source "$ENV_FILE"
set +a
# DEPLOY_MODE 必须是 public防止误把 local 的 env 复制过来)
if [[ "${DEPLOY_MODE:-}" != "public" ]]; then
warn "deploy/.env 中 DEPLOY_MODE=\"${DEPLOY_MODE:-<unset>}\",不是 public确认你要走公网部署"
fi
# MEDIASOUP_ANNOUNCED_IP 必填
if [[ -z "${MEDIASOUP_ANNOUNCED_IP:-}" ]]; then
err "MEDIASOUP_ANNOUNCED_IP 未设置,远端浏览器将无法建连 mediasoup"
log_info "请填写服务器的公网 IPMEDIASOUP_ANNOUNCED_IP=203.0.113.42"
else
log_ok "MEDIASOUP_ANNOUNCED_IP=${MEDIASOUP_ANNOUNCED_IP}"
fi
# 检查占位符没被替换
local placeholders=(
POSTGRES_PASSWORD
REDIS_PASSWORD
MINIO_ROOT_PASSWORD
JWT_SECRET
MEDIA_INTERNAL_TOKEN
TURN_USERNAME
TURN_PASSWORD
)
for var in "${placeholders[@]}"; do
local val="${!var:-}"
if [[ -z "$val" ]]; then
# REDIS_PASSWORD 允许为空(仅内网访问时)
if [[ "$var" == "REDIS_PASSWORD" ]]; then
warn "$var 为空(仅当 Redis 不对公网暴露时可接受)"
else
err "$var 为空"
fi
elif [[ "$val" == _REPLACE_WITH_* ]]; then
err "$var 仍是占位符(${val}),请替换为真实值"
fi
done
# JWT_SECRET 长度
if [[ -n "${JWT_SECRET:-}" && ${#JWT_SECRET} -lt 32 ]]; then
warn "JWT_SECRET 长度仅 ${#JWT_SECRET} 字符,推荐 >= 64 字符openssl rand -hex 32"
fi
# TURN 开关一致性
if [[ "${TURN_ENABLED:-false}" != "true" ]]; then
warn "TURN_ENABLED=${TURN_ENABLED:-false},公网部署下建议开启 TURN 作为对称 NAT fallback"
fi
if (( ERR_COUNT > 0 )); then
log_err "env 校验失败:$ERR_COUNT 个错误,$WARN_COUNT 个警告"
return 1
fi
log_ok "env 校验通过($WARN_COUNT 个警告)"
}
# ------------------------------------------------------------
# Step 2防火墙/端口占用检查(本机视角)
# ------------------------------------------------------------
# 说明:云安全组需要在云控制台放通,本脚本只能检测本机 iptables + 当前占用
step_check_ports() {
log_info "=== Step 2/4: 本机端口占用检查 ==="
local ports=(
"8085/tcp|Go backend API"
"3300/tcp|media-server (internal, 不建议暴露公网)"
"40000/udp|mediasoup RTC (端口范围起点)"
"40199/udp|mediasoup RTC (端口范围终点)"
)
if [[ "${TURN_ENABLED:-false}" == "true" ]]; then
ports+=(
"${TURN_LISTEN_PORT:-3478}/udp|coturn STUN/TURN"
"${TURN_LISTEN_PORT:-3478}/tcp|coturn STUN/TURN"
"${TURN_TLS_PORT:-5349}/tcp|coturn TLS"
)
fi
for entry in "${ports[@]}"; do
local port="${entry%%|*}"
local desc="${entry##*|}"
local port_num="${port%/*}"
local proto="${port##*/}"
if [[ "$proto" == "tcp" ]]; then
if lsof -iTCP:"$port_num" -sTCP:LISTEN -n -P >/dev/null 2>&1; then
warn "TCP:$port_num 已被占用(${desc}compose 启动可能失败"
else
log_ok "TCP:$port_num 空闲(${desc}"
fi
else
# UDP 只做提示,不中断
log_ok "UDP:$port_num 待 docker compose 绑定(${desc}"
fi
done
printf "\n${COLOR_YELLOW}⚠️ 云安全组/iptables 放通清单${COLOR_RESET}(本脚本无法自动执行,请手动确认):\n"
printf " TCP:8085 Go backend API\n"
printf " UDP:40000-40199 mediasoup RTC 流量\n"
printf " TCP:40000-40199 mediasoup ICE-TCP fallback\n"
if [[ "${TURN_ENABLED:-false}" == "true" ]]; then
printf " UDP:${TURN_LISTEN_PORT:-3478} coturn STUN/TURN\n"
printf " TCP:${TURN_LISTEN_PORT:-3478} coturn STUN/TURN\n"
printf " TCP:${TURN_TLS_PORT:-5349} coturn TLS\n"
printf " UDP:${TURN_MIN_PORT:-49160}-${TURN_MAX_PORT:-49200} coturn 中继流量\n"
fi
echo ""
}
# ------------------------------------------------------------
# Step 3Docker 环境自检
# ------------------------------------------------------------
step_check_docker() {
log_info "=== Step 3/4: Docker 环境自检 ==="
if ! command -v docker >/dev/null 2>&1; then
err "未检测到 docker 命令"
return 1
fi
log_ok "docker: $(docker --version)"
if ! docker info >/dev/null 2>&1; then
err "docker daemon 无法连接sudo systemctl start docker?"
return 1
fi
log_ok "docker daemon 可访问"
if ! docker compose version >/dev/null 2>&1; then
err "未检测到 docker compose需 Compose V2 plugin"
return 1
fi
log_ok "docker compose: $(docker compose version | head -n1)"
}
# ------------------------------------------------------------
# Step 4启动 public profile
# ------------------------------------------------------------
step_launch() {
log_info "=== Step 4/4: 启动 docker compose (public profile) ==="
if (( ERR_COUNT > 0 )); then
log_err "前面校验有错误,拒绝启动(先修正后再跑脚本)"
return 1
fi
cd "$DEPLOY_DIR"
if [[ "${TURN_ENABLED:-false}" == "true" ]]; then
log_info "TURN_ENABLED=true使用 --profile public 启动(含 coturn"
docker compose -f docker-compose.dev.yml --profile public up -d --build
else
log_info "TURN_ENABLED=false跳过 coturn仅默认服务"
docker compose -f docker-compose.dev.yml up -d --build
fi
log_ok "启动指令已发出,等待 healthcheck最多 180 秒)..."
local waited=0
while (( waited < 180 )); do
local health
health="$(docker inspect -f '{{.State.Health.Status}}' echochat-media-server 2>/dev/null || echo 'starting')"
if [[ "$health" == "healthy" ]]; then
log_ok "media-server healthy$waited 秒)"
break
fi
if [[ "$health" == "unhealthy" ]]; then
log_err "media-server unhealthy查看日志docker logs echochat-media-server"
return 1
fi
sleep 3
waited=$((waited + 3))
done
printf "\n${COLOR_GREEN}=============== 公网部署完成 ===============${COLOR_RESET}\n"
printf " Go 后端 API: http://${MEDIASOUP_ANNOUNCED_IP}:8085\n"
printf " media-server: http://127.0.0.1:3300 (仅内网,勿对外暴露)\n"
if [[ "${TURN_ENABLED:-false}" == "true" ]]; then
printf " coturn TURN: ${MEDIASOUP_ANNOUNCED_IP}:${TURN_LISTEN_PORT:-3478}\n"
fi
printf "\n 查看状态: docker compose -f deploy/docker-compose.dev.yml ps\n"
printf " 查看日志: docker compose -f deploy/docker-compose.dev.yml logs -f [service]\n"
printf " 停止全栈: ./scripts/stop.sh full\n"
printf "${COLOR_GREEN}============================================${COLOR_RESET}\n"
}
main() {
step_validate_env || exit 1
step_check_ports # Step 2不会 return 1只收集 warn
step_check_docker || exit 1 # Step 3
# 二次确认(至少暴露 1 个明显错误时已退出,这里只对警告放行)
if [[ "${1:-}" != "--yes" ]] && (( WARN_COUNT > 0 )); then
printf "\n${COLOR_YELLOW}上述 %d 个警告仅提示,不阻塞启动。继续部署?(y/N): ${COLOR_RESET}" "$WARN_COUNT"
read -r confirm
case "$confirm" in
y|Y|yes|YES) ;;
*) log_info "已取消部署"; exit 0 ;;
esac
fi
step_launch
}
main "$@"

View File

@@ -1,9 +1,11 @@
#!/usr/bin/env bash
# EchoChat 一键启动脚本
# 用法:
# ./scripts/start.sh # 启动全部Docker 中间件 + Go 后端 + 前台 + 管理端
# ./scripts/start.sh # 启动应用全部Docker 中间件 + Go/前端/管理端,本地进程跑
# ./scripts/start.sh --no-docker # 仅启动应用层(假设 Docker 容器已在运行)
# ./scripts/start.sh backend # 仅启动指定服务backend|frontend|admin|media|docker
# ./scripts/start.sh full # Phase 2e-2 Task 14docker compose 全栈启动(含 media-server 容器)
# # 会读 deploy/.env如不存在提示 copy example公网则用 deploy-public.sh
# 注意:不使用 set -e避免单个服务启动失败中断其他服务
# 失败时由 spawn_bg 内部打印日志末尾辅助排障
set -uo pipefail
@@ -12,6 +14,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="$ROOT_DIR/.run"
LOG_DIR="$RUN_DIR/logs"
DEPLOY_DIR="$ROOT_DIR/deploy"
mkdir -p "$RUN_DIR" "$LOG_DIR"
COLOR_GREEN='\033[0;32m'
@@ -66,6 +69,57 @@ start_docker() {
log_ok "Docker 中间件已启动"
}
# 确保 deploy/.env 存在:若缺失则从 .env.local.example 拷贝(等价于本机 Demo 默认值)
# 公网部署走 scripts/deploy-public.sh 单独校验,不经此函数
ensure_env_file() {
local env_file="$DEPLOY_DIR/.env"
if [[ -f "$env_file" ]]; then
return 0
fi
local tmpl="$DEPLOY_DIR/.env.local.example"
if [[ -f "$tmpl" ]]; then
cp "$tmpl" "$env_file"
log_warn "deploy/.env 不存在,已从 .env.local.example 复制(本机 Demo 默认值)"
else
log_warn "deploy/.env 和 .env.local.example 都不存在compose 将使用内置默认值"
fi
}
# Phase 2e-2 Task 14docker compose 全栈启动
# 默认只拉起非 public profile 的服务5 个postgres/redis/minio/go-service/media-server
# coturn 走 public profile需要用 scripts/deploy-public.sh
start_full() {
ensure_env_file
log_info "docker compose 全栈启动postgres + redis + minio + go-service + media-server..."
cd "$DEPLOY_DIR"
if ! docker compose -f docker-compose.dev.yml up -d --build; then
log_err "docker compose 启动失败,请检查上方错误输出"
return 1
fi
log_ok "全栈服务已发起启动,等待 healthcheck..."
# 等待最多 180 秒让 media-server 进入 healthy
local waited=0
while (( waited < 180 )); do
local health
health="$(docker inspect -f '{{.State.Health.Status}}' echochat-media-server 2>/dev/null || echo 'starting')"
case "$health" in
healthy)
log_ok "media-server 已 healthy$waited 秒)"
break
;;
unhealthy)
log_err "media-server unhealthy请查看日志docker logs echochat-media-server"
return 1
;;
esac
sleep 3
waited=$((waited + 3))
done
if (( waited >= 180 )); then
log_warn "等待 media-server healthy 超过 180 秒,可能仍在 mediasoup worker 初始化中,请手动验证"
fi
}
start_backend() {
if port_in_use 8085; then
log_warn "端口 8085 已被占用,跳过 Go 后端启动"
@@ -200,9 +254,21 @@ main() {
frontend) start_frontend ;;
admin) start_admin ;;
media) start_media ;;
full)
start_full || log_err "compose 全栈启动异常"
# full 模式下仍用本地进程跑前台 + 管理端(热更体验更好,符合开发机场景)
start_frontend || true
start_admin || true
print_summary
;;
*)
log_err "未知参数: $target"
echo "用法: $0 [all|docker|backend|frontend|admin|media|--no-docker]"
echo "用法: $0 [all|docker|backend|frontend|admin|media|full|--no-docker]"
echo ""
echo "模式说明:"
echo " all 本地进程启动应用 + docker 只起中间件(默认,开发常用)"
echo " full docker compose 全栈启动(含 media-server 容器,接近生产形态)"
echo " 公网部署 使用 scripts/deploy-public.sh校验 ANNOUNCED_IP + 启动 coturn"
exit 1
;;
esac

View File

@@ -56,5 +56,12 @@ check_container "echochat-redis" 6379
check_container "echochat-minio" 9000
echo ""
echo "日志目录:$RUN_DIR/logs"
echo "===== 容器化应用docker compose full ====="
check_container "echochat-go-service" 8085
check_container "echochat-media-server" 3300
check_container "echochat-coturn" 3478
echo ""
echo "日志目录:$RUN_DIR/logs"
echo "compose 日志docker compose -f deploy/docker-compose.dev.yml logs -f [service]"
echo ""

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# EchoChat 一键停止脚本
# 用法:
# ./scripts/stop.sh # 停止应用层Go 后端 + 前台 + 管理端),保留 Docker 容器
# ./scripts/stop.sh # 停止应用层Go 后端 + 前台 + 管理端 + 媒体),保留 Docker 容器
# ./scripts/stop.sh --all # 停止全部(含 Docker 中间件)
# ./scripts/stop.sh full # 停止 docker compose 全栈(含 media-server 容器,含 public profile 的 coturn
# ./scripts/stop.sh backend # 仅停止指定服务backend|frontend|admin|media|docker
# 不使用 set -e即使某一项停止失败其他项也要继续尝试
set -uo pipefail
@@ -10,6 +11,7 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="$ROOT_DIR/.run"
DEPLOY_DIR="$ROOT_DIR/deploy"
COLOR_GREEN='\033[0;32m'
COLOR_YELLOW='\033[0;33m'
@@ -88,6 +90,15 @@ stop_docker() {
log_ok "Docker 中间件已停止(数据卷保留)"
}
# Phase 2e-2 Task 14停止 docker compose 全栈(含 media-server、coturn
# 使用 --profile public 确保即使 coturn 在 public profile 下运行也能被停掉
stop_full() {
log_info "停止 docker compose 全栈(含 media-server / coturn..."
cd "$DEPLOY_DIR"
docker compose -f docker-compose.dev.yml --profile public stop || true
log_ok "全栈服务已停止(数据卷保留)"
}
main() {
local target="${1:-app}"
@@ -112,8 +123,9 @@ main() {
admin) stop_admin ;;
media) stop_media ;;
docker) stop_docker ;;
full) stop_full ;;
*)
echo "用法: $0 [app|--all|backend|frontend|admin|media|docker]"
echo "用法: $0 [app|--all|backend|frontend|admin|media|docker|full]"
exit 1
;;
esac