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:
@@ -27,7 +27,7 @@ alwaysApply: true
|
|||||||
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
|
* 单端 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`
|
* 专用设计:`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`
|
* 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)
|
* 专用设计:`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),详见设计文档 §三
|
* 11 项关键决策已锁定(D01-D11),详见设计文档 §三
|
||||||
* **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
|
* **重要修订**:`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` 级联关闭下游 consumer;Consumer 强制 `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,较首版 +2pp);9 接口 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/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单
|
* **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` 级联关闭下游 consumer;Consumer 强制 `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,较首版 +2pp);9 接口 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/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单
|
||||||
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成(2026-04-21)**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQL,DDL 同时写入 `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 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成(2026-04-21)**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQL,DDL 同时写入 `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 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 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 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+ action,WS 监听进房注册/离房注销);扩展 `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 全部 Consumer,Task 11 重构为 `Map<producerId, consumer>` 精细索引;(c) Chrome 两 tab 手测 + WS 断线重入会需用户本地运行验证,Task 16 补 Playwright 自动化脚本回归
|
* **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+ action,WS 监听进房注册/离房注销);扩展 `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 全部 Consumer,Task 11 重构为 `Map<producerId, consumer>` 精细索引;(c) Chrome 两 tab 手测 + WS 断线重入会需用户本地运行验证,Task 16 补 Playwright 自动化脚本回归
|
||||||
|
|||||||
76
deploy/.env.example
Normal file
76
deploy/.env.example
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ============================================================
|
||||||
|
# EchoChat 部署环境变量示例(通用)
|
||||||
|
# 本文件为主模板,含所有可配置项 + 默认值说明
|
||||||
|
# 选择部署形态时请复制为 .env.local 或 .env.public,然后按需修改
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 本机 Demo:cp 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 监听 IP:Docker 内部一律 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):
|
||||||
|
# - 本机 Demo:false
|
||||||
|
# - 公网部署:推荐 true(对称 NAT 用户的 fallback 通路)
|
||||||
|
TURN_ENABLED=false
|
||||||
|
|
||||||
|
# coturn 提供的 realm、用户名、密码
|
||||||
|
# 生产务必改,TURN 账号泄漏可被滥用
|
||||||
|
TURN_REALM=echochat
|
||||||
|
TURN_USERNAME=echouser
|
||||||
|
TURN_PASSWORD=echopass
|
||||||
|
|
||||||
|
# TURN 外部监听端口(标准 3478 STUN/TURN,5349 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
32
deploy/.env.local.example
Normal 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
|
||||||
75
deploy/.env.public.example
Normal file
75
deploy/.env.public.example
Normal 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 会自动进行一轮参数 + 端口校验
|
||||||
|
# ============================================================
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
services:
|
services:
|
||||||
|
# ============================================================
|
||||||
|
# 中间件层:Postgres / Redis / MinIO
|
||||||
|
# ============================================================
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: echochat-postgres
|
container_name: echochat-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: echochat
|
POSTGRES_DB: ${POSTGRES_DB:-echochat}
|
||||||
POSTGRES_USER: echochat
|
POSTGRES_USER: ${POSTGRES_USER:-echochat}
|
||||||
POSTGRES_PASSWORD: echochat_dev_2026
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-echochat_dev_2026}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U echochat"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-echochat}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -40,8 +43,8 @@ services:
|
|||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: echochat
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-echochat}
|
||||||
MINIO_ROOT_PASSWORD: echochat123456
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-echochat123456}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -51,6 +54,9 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 应用层:Go backend
|
||||||
|
# ============================================================
|
||||||
go-service:
|
go-service:
|
||||||
build:
|
build:
|
||||||
context: ../backend/go-service
|
context: ../backend/go-service
|
||||||
@@ -58,8 +64,11 @@ services:
|
|||||||
container_name: echochat-go-service
|
container_name: echochat-go-service
|
||||||
environment:
|
environment:
|
||||||
CONFIG_NAME: config.docker
|
CONFIG_NAME: config.docker
|
||||||
|
# 透传关键环境变量,config.docker.yaml 内部引用(可选覆盖)
|
||||||
|
ECHOCHAT_JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
ECHOCHAT_MEDIA_SERVER_INTERNAL_TOKEN: ${MEDIA_INTERNAL_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "8085:8085"
|
- "${GO_SERVICE_PORT:-8085}:8085"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -67,6 +76,73 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
258
docs/deployment/meeting-mvp.md
Normal file
258
docs/deployment/meeting-mvp.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# EchoChat 会议 MVP 部署指南
|
||||||
|
|
||||||
|
> **适用阶段**:Phase 2e-2(会议 MVP)
|
||||||
|
> **覆盖形态**:本机 Demo(零配置)+ 公网部署(含 TURN)
|
||||||
|
> **最后更新**:2026-04-23(Task 14 双态部署脚本 + docker-compose 扩展完成)
|
||||||
|
|
||||||
|
## 一、双态部署总览
|
||||||
|
|
||||||
|
EchoChat 会议 MVP 按 Phase 2e-2 设计决策 D01 采用**"一套代码 + 环境变量切换"** 的双态策略,不做代码分支。
|
||||||
|
|
||||||
|
| 对比项 | 本机 Demo(local) | 公网部署(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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q6:public 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)(如有)
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Phase 2e-2 实施计划:会议 MVP(多人音视频)
|
# 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-2 设计文档](./2026-04-21-phase2e-2-design.md)
|
||||||
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
|
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
|
||||||
> **分支:** `feature/phase2e-2-meeting-mvp`
|
> **分支:** `feature/phase2e-2-meeting-mvp`
|
||||||
> **预估总工时:** **约 17 人日**(17 个 Task,含 PoC 与 UI 打磨)
|
> **预估总工时:** **约 17 人日**(17 个 Task,含 PoC 与 UI 打磨)
|
||||||
> **最后更新:** 2026-04-23(Task 13 ✅ meeting_invite 通知卡片对接完成,后端 extra 补齐 inviter_id/inviter_name/expired_at,前端 NotifyItem.vue 支持"立即加入/稍后"按钮 + 过期态 + deep-link;顺带修复 WS .ack 事件不分发导致的消息卡圈 bug。下一步 Task 14 docker-compose 双态部署)
|
> **最后更新:** 2026-04-24(Task 14 ✅ docker-compose 双态扩展完成:新增 media-server + coturn(public 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 11** | **会议室主页 + 核心组件** | ✅ | `room.vue` + VideoGrid/VideoTile/Toolbar/MemberPanel/InviteDialog |
|
||||||
| **Task 12** | **会议内聊天面板** | ✅ | `ChatPanel.vue` + WS `meeting.chat.new` + user_name/avatar 补齐 |
|
| **Task 12** | **会议内聊天面板** | ✅ | `ChatPanel.vue` + WS `meeting.chat.new` + user_name/avatar 补齐 |
|
||||||
| **Task 13** | **meeting_invite 通知对接** | ✅ | extra 补齐 inviter_* / expired_at;前端"立即加入/稍后"+ 过期态 + deep-link |
|
| **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 15 | 观测性与告警 | ⏳ | |
|
||||||
| Task 16 | E2E + 文档同步 | ⏳ | |
|
| Task 16 | E2E + 文档同步 | ⏳ | |
|
||||||
|
|
||||||
@@ -517,22 +517,30 @@ flowchart LR
|
|||||||
- 过期态由 `expired_at` 驱动(Redis TTL 600s),过期后按钮自动灰显、点击有 toast 提示、不跳转。
|
- 过期态由 `expired_at` 驱动(Redis TTL 600s),过期后按钮自动灰显、点击有 toast 提示、不跳转。
|
||||||
- **工作量**:**0.5 人日(实际约 0.4 人日,含 Playwright 回归补做)**
|
- **工作量**:**0.5 人日(实际约 0.4 人日,含 Playwright 回归补做)**
|
||||||
|
|
||||||
### Task 14:docker-compose 扩展 + 环境变量双态开关
|
### Task 14:docker-compose 扩展 + 环境变量双态开关 ✅
|
||||||
|
|
||||||
- **目标**:本机和公网双态部署脚本 + 文档
|
- **目标**:本机和公网双态部署脚本 + 文档
|
||||||
- **依赖**:T11
|
- **依赖**:T11
|
||||||
- **主要产出**:
|
- **实际产出(2026-04-24)**:
|
||||||
- `deploy/docker-compose.dev.yml` 新增 `media-server` 服务 + `coturn`(`profiles: [public]`)
|
- `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`。
|
||||||
- `.env.example` / `.env.local.example` / `.env.public.example` 三份配置
|
- `deploy/.env.example`:总模板,涵盖 `DEPLOY_MODE` / DB / backend / media-server / coturn 全部字段。
|
||||||
- `scripts/start.sh` 扩展 `media` / `full` 子命令
|
- `deploy/.env.local.example`:本机 Demo 预填值,`MEDIASOUP_ANNOUNCED_IP=""`(自动内网),`TURN_ENABLED=false`。
|
||||||
- `scripts/stop.sh` / `scripts/status.sh` 同步
|
- `deploy/.env.public.example`:公网部署模板,所有敏感字段使用 `_REPLACE_WITH_*_` 占位符 + 部署 checklist。
|
||||||
- `scripts/deploy-public.sh` 新建:校验 `MEDIASOUP_ANNOUNCED_IP` 非空 + 检查防火墙端口 + 启动 coturn profile
|
- `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。
|
||||||
- 文档:`docs/deployment/meeting-mvp.md` 新建,涵盖本机 + 公网两种流程 + 常见问题
|
- `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/start.sh full` 全量启动,5 分钟内全部服务就绪
|
- `scripts/deploy-public.sh`(**新建**,`chmod +x`):
|
||||||
- 公网(模拟):`MEDIASOUP_ANNOUNCED_IP=x.x.x.x docker compose --profile public up` 启动无报错
|
- `step_validate_env`:校验 `.env` 存在、`DEPLOY_MODE=public`、`MEDIASOUP_ANNOUNCED_IP` 非空、所有 `_REPLACE_WITH_*_` 占位符已替换。
|
||||||
- 防火墙校验脚本能识别 UDP 40000-40199 未开放并给出提示
|
- `step_check_ports`:列出本机需要放行的 TCP/UDP 端口(8085 / 3300 / 40000-40199 / 3478 / 49152-65535)+ 云厂商安全组 checklist。
|
||||||
- **工作量**:**0.5 人日**
|
- `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 打磨
|
### Task 15:`ui-ux-pro-max` 定制 UI 打磨
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-04-23(Phase 2e-2 Task 13 meeting_invite 通知卡片对接完成 + 修复 WS .ack 事件未分发导致的消息卡圈回归 bug)
|
> **最后更新**:2026-04-24(Phase 2e-2 Task 14 docker-compose 双态扩展完成:media-server + coturn 容器编排 + 三份 .env 模板 + deploy-public.sh + docs/deployment/meeting-mvp.md 双态部署指南)
|
||||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-13 ✅ / Task 14-16 待执行)
|
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-14 ✅ / Task 15-16 待执行)
|
||||||
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
||||||
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
||||||
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-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 14:docker-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` 通知卡片对接完成
|
## 🎯 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 圆圈、下方「已读 / 未读」标签也永不渲染。
|
**交付**:打通「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
256
scripts/deploy-public.sh
Executable 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 1:env 文件校验
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
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 "请填写服务器的公网 IP,例:MEDIASOUP_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 3:Docker 环境自检
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
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 "$@"
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# EchoChat 一键启动脚本
|
# EchoChat 一键启动脚本
|
||||||
# 用法:
|
# 用法:
|
||||||
# ./scripts/start.sh # 启动全部(Docker 中间件 + Go 后端 + 前台 + 管理端)
|
# ./scripts/start.sh # 启动应用全部(Docker 中间件 + Go/前端/管理端,本地进程跑)
|
||||||
# ./scripts/start.sh --no-docker # 仅启动应用层(假设 Docker 容器已在运行)
|
# ./scripts/start.sh --no-docker # 仅启动应用层(假设 Docker 容器已在运行)
|
||||||
# ./scripts/start.sh backend # 仅启动指定服务(backend|frontend|admin|media|docker)
|
# ./scripts/start.sh backend # 仅启动指定服务(backend|frontend|admin|media|docker)
|
||||||
|
# ./scripts/start.sh full # Phase 2e-2 Task 14:docker compose 全栈启动(含 media-server 容器)
|
||||||
|
# # 会读 deploy/.env(如不存在提示 copy example),公网则用 deploy-public.sh
|
||||||
# 注意:不使用 set -e,避免单个服务启动失败中断其他服务;
|
# 注意:不使用 set -e,避免单个服务启动失败中断其他服务;
|
||||||
# 失败时由 spawn_bg 内部打印日志末尾辅助排障
|
# 失败时由 spawn_bg 内部打印日志末尾辅助排障
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -12,6 +14,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
RUN_DIR="$ROOT_DIR/.run"
|
RUN_DIR="$ROOT_DIR/.run"
|
||||||
LOG_DIR="$RUN_DIR/logs"
|
LOG_DIR="$RUN_DIR/logs"
|
||||||
|
DEPLOY_DIR="$ROOT_DIR/deploy"
|
||||||
mkdir -p "$RUN_DIR" "$LOG_DIR"
|
mkdir -p "$RUN_DIR" "$LOG_DIR"
|
||||||
|
|
||||||
COLOR_GREEN='\033[0;32m'
|
COLOR_GREEN='\033[0;32m'
|
||||||
@@ -66,6 +69,57 @@ start_docker() {
|
|||||||
log_ok "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 14:docker 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() {
|
start_backend() {
|
||||||
if port_in_use 8085; then
|
if port_in_use 8085; then
|
||||||
log_warn "端口 8085 已被占用,跳过 Go 后端启动"
|
log_warn "端口 8085 已被占用,跳过 Go 后端启动"
|
||||||
@@ -200,9 +254,21 @@ main() {
|
|||||||
frontend) start_frontend ;;
|
frontend) start_frontend ;;
|
||||||
admin) start_admin ;;
|
admin) start_admin ;;
|
||||||
media) start_media ;;
|
media) start_media ;;
|
||||||
|
full)
|
||||||
|
start_full || log_err "compose 全栈启动异常"
|
||||||
|
# full 模式下仍用本地进程跑前台 + 管理端(热更体验更好,符合开发机场景)
|
||||||
|
start_frontend || true
|
||||||
|
start_admin || true
|
||||||
|
print_summary
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
log_err "未知参数: $target"
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -56,5 +56,12 @@ check_container "echochat-redis" 6379
|
|||||||
check_container "echochat-minio" 9000
|
check_container "echochat-minio" 9000
|
||||||
|
|
||||||
echo ""
|
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 ""
|
echo ""
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# EchoChat 一键停止脚本
|
# EchoChat 一键停止脚本
|
||||||
# 用法:
|
# 用法:
|
||||||
# ./scripts/stop.sh # 停止应用层(Go 后端 + 前台 + 管理端),保留 Docker 容器
|
# ./scripts/stop.sh # 停止应用层(Go 后端 + 前台 + 管理端 + 媒体),保留 Docker 容器
|
||||||
# ./scripts/stop.sh --all # 停止全部(含 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)
|
# ./scripts/stop.sh backend # 仅停止指定服务(backend|frontend|admin|media|docker)
|
||||||
# 不使用 set -e:即使某一项停止失败,其他项也要继续尝试
|
# 不使用 set -e:即使某一项停止失败,其他项也要继续尝试
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -10,6 +11,7 @@ set -uo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
RUN_DIR="$ROOT_DIR/.run"
|
RUN_DIR="$ROOT_DIR/.run"
|
||||||
|
DEPLOY_DIR="$ROOT_DIR/deploy"
|
||||||
|
|
||||||
COLOR_GREEN='\033[0;32m'
|
COLOR_GREEN='\033[0;32m'
|
||||||
COLOR_YELLOW='\033[0;33m'
|
COLOR_YELLOW='\033[0;33m'
|
||||||
@@ -88,6 +90,15 @@ stop_docker() {
|
|||||||
log_ok "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() {
|
main() {
|
||||||
local target="${1:-app}"
|
local target="${1:-app}"
|
||||||
|
|
||||||
@@ -112,8 +123,9 @@ main() {
|
|||||||
admin) stop_admin ;;
|
admin) stop_admin ;;
|
||||||
media) stop_media ;;
|
media) stop_media ;;
|
||||||
docker) stop_docker ;;
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Reference in New Issue
Block a user