diff --git a/README.md b/README.md index 2bdfda23..1c4a7087 100644 --- a/README.md +++ b/README.md @@ -143,15 +143,18 @@ MySQL 是本地和线上运行的持久化底座: `room-service` uses Redis for current room ownership: ```text -room:route:{room_id} +room:route:{app_code + "\0" + room_id} +room:node:{node_id} ``` -Redis lease 只保存当前执行权,不能作为恢复来源。恢复来源是 MySQL snapshot + command log。 +Redis lease 只保存当前执行权和节点转发地址,不能作为恢复来源。恢复来源是 MySQL snapshot + command log。 ## Room Recovery A room is not permanently bound to one process. A node is only the current executor while its Redis lease is valid. +在多机部署下,gateway 可以把 room gRPC 请求打到 `room-service` 内网 CLB。非 owner 节点会读取 `room:route:*` 和 `room:node:*`,把房间命令、IM guard 和完整快照查询转发到当前 owner 实例。每个 room 节点必须配置唯一 `node_id` 和实例级 `advertise_addr`;`advertise_addr` 不能写负载均衡地址。无 Kubernetes 的部署步骤见 `docs/多机部署与无感发布.md`。 + Recovery path: 1. A request reaches a node that can own the room. diff --git a/deploy/standalone/README.md b/deploy/standalone/README.md new file mode 100644 index 00000000..a32d1aff --- /dev/null +++ b/deploy/standalone/README.md @@ -0,0 +1,71 @@ +# Standalone CVM Deployment Templates + +本目录提供不依赖 Kubernetes 的生产部署模板。目标形态是 systemd 托管 Docker 容器,公网 CLB 只接 gateway,内部服务通过内网 CLB/DNS 或固定私网地址访问。 + +## Layout + +- `systemd/*.service`: 每个后端服务一份 systemd unit,负责启动、停止和故障重启 Docker 容器。 +- `docker/*.env.example`: 每个服务一份 Docker 运行参数示例,只保存镜像名、容器名、配置路径和停止等待时间。 +- 服务业务配置仍使用 `services//configs/config.tencent.example.yaml` 作为线上模板,渲染后放到 `/etc/hyapp//config.yaml`。 + +## Install One Service + +以 `gateway-service` 为例: + +```bash +sudo mkdir -p /etc/hyapp/gateway-service +sudo cp deploy/standalone/docker/gateway-service.env.example /etc/hyapp/gateway-service/docker.env +sudo cp services/gateway-service/configs/config.tencent.example.yaml /etc/hyapp/gateway-service/config.yaml +sudo vi /etc/hyapp/gateway-service/docker.env +sudo vi /etc/hyapp/gateway-service/config.yaml + +sudo cp deploy/standalone/systemd/hyapp-gateway-service.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now hyapp-gateway-service +sudo systemctl status hyapp-gateway-service +sudo docker inspect --format '{{json .State.Health}}' hyapp-gateway-service +``` + +`docker.env` 不承载业务密钥;密钥仍在最终 `config.yaml` 中,或者由发布系统先从密钥系统渲染到 `config.yaml`。当前代码只读取单个 YAML,不会自动展开 `${VAR}`。 + +## Network + +模板默认使用 `--network=host`,让容器直接监听项目约定的 `13xxx` 端口。这样 CLB、安全组和本机排障都以同一个端口工作: + +- `13000`: gateway HTTP +- `13001`: room gRPC +- `13004`: wallet gRPC +- `13005`: user gRPC +- `13006`: activity gRPC +- `13007`: cron gRPC +- `13008`: game gRPC + +同一台 CVM 上不要启动两个相同端口的同服务实例。做蓝绿发布时优先使用新 CVM 或新容器节点,健康通过后再挂载到 CLB。 + +## Rolling Update + +普通服务的无感更新流程: + +1. 在新 CVM 上准备 `/etc/hyapp//docker.env` 和 `/etc/hyapp//config.yaml`。 +2. `systemctl start hyapp-`,等待 Docker health 进入 `healthy`。 +3. 将新实例加入对应 CLB 或内部服务入口。 +4. 观察错误率、延迟和服务日志。 +5. 从 CLB 摘除旧实例,等待连接保护或业务 drain 窗口结束。 +6. `systemctl stop hyapp-`,Docker 会先发送 SIGTERM,服务内的 graceful shutdown 会进入 draining 并等待已进入请求结束。 + +`room-service` 当前还没有 gateway owner-routing 或非 owner 自动转发能力。没有补完该能力前,不要把多个 `room-service` active 实例直接放到普通负载均衡后面承接同一批房间命令;生产先保持单 active,或者只把备用实例作为故障接管目标。 + +## Service Discovery + +不使用 Kubernetes 时,第一阶段推荐用内网 CLB/DNS 提供稳定入口,例如: + +```yaml +user_service_addr: "user.service.internal:13005" +wallet_service_addr: "wallet.service.internal:13004" +activity_service_addr: "activity.service.internal:13006" +room_service_addr: "room.service.internal:13001" +game_service_addr: "game.service.internal:13008" +``` + +如果后续引入 Consul/Nacos,仍建议让业务配置只保存稳定服务名,不把实例 IP 写进每个服务配置。 + diff --git a/deploy/standalone/docker/activity-service.env.example b/deploy/standalone/docker/activity-service.env.example new file mode 100644 index 00000000..6ecd04d5 --- /dev/null +++ b/deploy/standalone/docker/activity-service.env.example @@ -0,0 +1,6 @@ +# Image is immutable per release; publish scripts pull this tag before restarting systemd. +IMAGE=ccr.ccs.tencentyun.com/hyapp/activity-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-activity-service +CONFIG_PATH=/etc/hyapp/activity-service/config.yaml +STOP_TIMEOUT_SEC=60 + diff --git a/deploy/standalone/docker/cron-service.env.example b/deploy/standalone/docker/cron-service.env.example new file mode 100644 index 00000000..cc526a93 --- /dev/null +++ b/deploy/standalone/docker/cron-service.env.example @@ -0,0 +1,6 @@ +# cron-service already uses MySQL task leases, but keep one replica until every task is verified under multi-node scheduling. +IMAGE=ccr.ccs.tencentyun.com/hyapp/cron-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-cron-service +CONFIG_PATH=/etc/hyapp/cron-service/config.yaml +STOP_TIMEOUT_SEC=90 + diff --git a/deploy/standalone/docker/game-service.env.example b/deploy/standalone/docker/game-service.env.example new file mode 100644 index 00000000..6b17afa4 --- /dev/null +++ b/deploy/standalone/docker/game-service.env.example @@ -0,0 +1,5 @@ +IMAGE=ccr.ccs.tencentyun.com/hyapp/game-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-game-service +CONFIG_PATH=/etc/hyapp/game-service/config.yaml +STOP_TIMEOUT_SEC=60 + diff --git a/deploy/standalone/docker/gateway-service.env.example b/deploy/standalone/docker/gateway-service.env.example new file mode 100644 index 00000000..2cd909c3 --- /dev/null +++ b/deploy/standalone/docker/gateway-service.env.example @@ -0,0 +1,6 @@ +# gateway instances are stateless HTTP entries and should be attached behind the public CLB only after /healthz/ready passes. +IMAGE=ccr.ccs.tencentyun.com/hyapp/gateway-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-gateway-service +CONFIG_PATH=/etc/hyapp/gateway-service/config.yaml +STOP_TIMEOUT_SEC=60 + diff --git a/deploy/standalone/docker/room-service.env.example b/deploy/standalone/docker/room-service.env.example new file mode 100644 index 00000000..69a5dcd6 --- /dev/null +++ b/deploy/standalone/docker/room-service.env.example @@ -0,0 +1,6 @@ +# Keep room-service single-active until owner routing or non-owner forwarding is implemented. +IMAGE=ccr.ccs.tencentyun.com/hyapp/room-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-room-service +CONFIG_PATH=/etc/hyapp/room-service/config.yaml +STOP_TIMEOUT_SEC=90 + diff --git a/deploy/standalone/docker/user-service.env.example b/deploy/standalone/docker/user-service.env.example new file mode 100644 index 00000000..0e96d2d2 --- /dev/null +++ b/deploy/standalone/docker/user-service.env.example @@ -0,0 +1,6 @@ +# id_generator.node_id in config.yaml must be unique for every active user-service instance. +IMAGE=ccr.ccs.tencentyun.com/hyapp/user-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-user-service +CONFIG_PATH=/etc/hyapp/user-service/config.yaml +STOP_TIMEOUT_SEC=60 + diff --git a/deploy/standalone/docker/wallet-service.env.example b/deploy/standalone/docker/wallet-service.env.example new file mode 100644 index 00000000..16cafd35 --- /dev/null +++ b/deploy/standalone/docker/wallet-service.env.example @@ -0,0 +1,5 @@ +IMAGE=ccr.ccs.tencentyun.com/hyapp/wallet-service:REPLACE_WITH_RELEASE_TAG +CONTAINER_NAME=hyapp-wallet-service +CONFIG_PATH=/etc/hyapp/wallet-service/config.yaml +STOP_TIMEOUT_SEC=60 + diff --git a/deploy/standalone/systemd/hyapp-activity-service.service b/deploy/standalone/systemd/hyapp-activity-service.service new file mode 100644 index 00000000..ca3c5887 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-activity-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp activity-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/activity-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13006 -service=activity-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=90 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/standalone/systemd/hyapp-cron-service.service b/deploy/standalone/systemd/hyapp-cron-service.service new file mode 100644 index 00000000..5cddf897 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-cron-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp cron-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/cron-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13007 -service=cron-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=120 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/standalone/systemd/hyapp-game-service.service b/deploy/standalone/systemd/hyapp-game-service.service new file mode 100644 index 00000000..9845d694 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-game-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp game-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/game-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13008 -service=game-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=90 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/standalone/systemd/hyapp-gateway-service.service b/deploy/standalone/systemd/hyapp-gateway-service.service new file mode 100644 index 00000000..71eaf1b1 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-gateway-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp gateway-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/gateway-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="wget -q -O - http://127.0.0.1:13000/healthz/ready >/dev/null" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=90 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/standalone/systemd/hyapp-room-service.service b/deploy/standalone/systemd/hyapp-room-service.service new file mode 100644 index 00000000..09eb1104 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-room-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp room-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/room-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13001 -service=room-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=120 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/standalone/systemd/hyapp-user-service.service b/deploy/standalone/systemd/hyapp-user-service.service new file mode 100644 index 00000000..c83c1d7d --- /dev/null +++ b/deploy/standalone/systemd/hyapp-user-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp user-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/user-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13005 -service=user-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=90 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/standalone/systemd/hyapp-wallet-service.service b/deploy/standalone/systemd/hyapp-wallet-service.service new file mode 100644 index 00000000..a49dbc97 --- /dev/null +++ b/deploy/standalone/systemd/hyapp-wallet-service.service @@ -0,0 +1,24 @@ +[Unit] +Description=Hyapp wallet-service container +Requires=docker.service +After=network-online.target docker.service +Wants=network-online.target +StartLimitIntervalSec=120 +StartLimitBurst=3 + +[Service] +Type=simple +EnvironmentFile=/etc/hyapp/wallet-service/docker.env +ExecStartPre=-/usr/bin/docker rm -f ${CONTAINER_NAME} +ExecStart=/usr/bin/docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE} +ExecStop=/usr/bin/docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME} +Restart=on-failure +RestartSec=5s +TimeoutStartSec=60 +TimeoutStopSec=90 +KillMode=none +LimitNOFILE=1048576 + +[Install] +WantedBy=multi-user.target + diff --git a/docker-compose.yml b/docker-compose.yml index f66ac9fb..3a17a524 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: TZ: UTC ports: - "13001:13001" + - "13101:13101" depends_on: mysql: condition: service_healthy @@ -47,7 +48,7 @@ services: wallet-service: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13001 -service=room-service"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13101/healthz/ready >/dev/null"] interval: 5s timeout: 3s retries: 20 @@ -63,6 +64,7 @@ services: TZ: UTC ports: - "13004:13004" + - "13104:13104" depends_on: mysql: condition: service_healthy @@ -71,7 +73,7 @@ services: activity-service: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13104/healthz/ready >/dev/null"] interval: 5s timeout: 3s retries: 20 @@ -87,11 +89,12 @@ services: TZ: UTC ports: - "13005:13005" + - "13105:13105" depends_on: mysql: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13005 -service=user-service"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13105/healthz/ready >/dev/null"] interval: 5s timeout: 3s retries: 20 @@ -107,13 +110,14 @@ services: TZ: UTC ports: - "13006:13006" + - "13106:13106" depends_on: mysql: condition: service_healthy user-service: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13006 -service=activity-service"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13106/healthz/ready >/dev/null"] interval: 5s timeout: 3s retries: 20 @@ -129,6 +133,7 @@ services: TZ: UTC ports: - "13007:13007" + - "13107:13107" depends_on: mysql: condition: service_healthy @@ -137,7 +142,7 @@ services: activity-service: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13007 -service=cron-service"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13107/healthz/ready >/dev/null"] interval: 5s timeout: 3s retries: 20 @@ -153,6 +158,7 @@ services: TZ: UTC ports: - "13008:13008" + - "13108:13108" depends_on: mysql: condition: service_healthy @@ -161,7 +167,7 @@ services: user-service: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "/app/grpc-health-probe -addr=127.0.0.1:13008 -service=game-service"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13108/healthz/ready >/dev/null"] interval: 5s timeout: 3s retries: 20 diff --git a/docs/优雅停机.md b/docs/优雅停机.md index 3ff471e8..d494b580 100644 --- a/docs/优雅停机.md +++ b/docs/优雅停机.md @@ -126,7 +126,8 @@ SIGINT - 对已经进入处理中的命令,允许在 deadline 内完成提交。 - 命令提交后必须完成必要 command log、snapshot dirty 标记和 outbox 写入。 - 对本节点持有的 Room Cell 执行 snapshot flush。 -- 停止 lease 续约,或按实现安全释放 lease。 +- 停止 node registry 心跳;gRPC drain 完成后删除 `room:node:{node_id}`。 +- gRPC drain 完成后释放本节点仍持有的已加载房间 lease,让其他节点无需等待 `lease_ttl` 才能接管。 - outbox worker 处理完当前批次,未处理事件保持在 MySQL 中等待其他 worker 或下次启动补投。 Room Cell 下线顺序: @@ -137,7 +138,9 @@ Room Cell 下线顺序: 3. command log/outbox transaction completes 4. snapshot flush runs with timeout 5. lease renew stops -6. cell is removed from local manager +6. owner route is released after in-flight commands finish +7. node registry is removed +8. cell is removed from local manager ``` 如果 shutdown timeout 到期: @@ -145,6 +148,7 @@ Room Cell 下线顺序: - 不能删除未安全释放的持久状态。 - 不能清空 outbox。 - 可以让 Redis lease 自然过期,由其他节点通过 snapshot + command log 恢复。 +- 如果主动释放 lease 未完成,不能强删 MySQL 状态;让 Redis TTL 自然过期是安全兜底。 禁止行为: diff --git a/docs/健康检查.md b/docs/健康检查.md index efd6e389..e442b9da 100644 --- a/docs/健康检查.md +++ b/docs/健康检查.md @@ -147,6 +147,7 @@ activity-service - 当前节点不处于 draining 状态。 - gRPC listener 已启动。 - Redis lease 操作能力正常,至少能执行一次轻量读写或 Lua 脚本探测。 +- 节点启动时必须能写入 `room:node:{node_id}`;运行期心跳失败要记录告警,避免其他 room 节点无法转发到当前 owner。 `wallet-service` 建议作为 `SendGift` 链路的强依赖监控,但不要默认作为 `room-service` 整体 ready 的硬失败条件;否则 wallet 短暂异常会导致房间基础命令也被摘流。 diff --git a/docs/多机部署与无感发布.md b/docs/多机部署与无感发布.md new file mode 100644 index 00000000..b9d050b5 --- /dev/null +++ b/docs/多机部署与无感发布.md @@ -0,0 +1,112 @@ +# 多机部署与无感发布 + +本文定义不使用 Kubernetes 时的生产部署边界。目标是让 gateway 和内部微服务可以多机滚动发布,并让 `room-service` 在普通内网负载均衡命中非 owner 节点时自动转发到当前 Room Cell owner。 + +## 网络拓扑 + +推荐拓扑: + +```text +公网 CLB + -> gateway-service 多实例 + -> room-service 内网入口 + -> user-service 内网入口 + -> wallet-service 内网入口 + -> activity-service 内网入口 + -> game-service 内网入口 + +room-service 实例之间通过 Redis owner route 和 node registry 直连转发。 +``` + +规则: + +- 公网只暴露 `gateway-service`。 +- 内部 gRPC 服务只监听私网,安全组只允许服务网段访问 `13xxx` 端口。 +- `user-service`、`wallet-service`、`activity-service`、`game-service` 可以直接挂内网 CLB 或 DNS。 +- `room-service` 可以挂内网 CLB/DNS 给 gateway 使用,但 owner 转发必须使用实例级 `advertise_addr`,不能使用 CLB 地址。 + +## Room Owner 路由 + +`room-service` 使用 Redis 维护两个短租约: + +```text +room:route:{app_code + "\0" + room_id} -> owner node_id / lease_token / expires_at_ms +room:node:{node_id} -> 实例 advertise_addr / expires_at_ms +``` + +写命令、IM guard 和完整房间快照查询的处理顺序: + +1. 请求先到任意 `room-service` 节点。 +2. 节点按 `app_code + "\0" + room_id` 查询 Redis owner route。 +3. 如果当前节点就是 owner,直接进入本地 Room Cell。 +4. 如果 owner 是其他节点,按 `room:node:{owner_node_id}` 找到实例私网地址并转发 gRPC。 +5. 如果 route 不存在或已过期,当前节点按原有 `EnsureOwner` 逻辑接管并从 MySQL snapshot + command log 恢复。 + +转发只做一跳。`advertise_addr` 写错成 CLB 地址时,请求会在一跳后停止递归,并暴露真实 owner 冲突或 unavailable。 + +## room-service 配置 + +每个实例必须有唯一配置: + +```yaml +node_id: "room-node-prod-1" +grpc_addr: ":13001" +advertise_addr: "10.0.1.12:13001" +health_http_addr: ":13101" +node_registry_ttl: "30s" +node_registry_heartbeat_interval: "10s" +owner_forward_timeout: "2s" +lease_ttl: "10s" +``` + +配置边界: + +- `grpc_addr` 是本进程监听地址,可以是 `:13001`。 +- `advertise_addr` 是其他 room 节点能直连的实例私网地址,必须是单实例地址。 +- `node_id` 参与 room owner lease、节点注册和日志排障,不能在多个实例间复用。 +- `node_registry_ttl` 必须大于心跳周期;默认 `30s/10s`。 +- `lease_ttl` 是节点故障后的自动接管窗口;正常滚动发布会在 gRPC drain 后主动释放本节点已加载房间 lease,减少等待 TTL 的窗口。 + +## 无感发布步骤 + +单个服务滚动发布顺序: + +1. 在新机器或新端口启动新版本实例。 +2. 等 `health_http_addr` 的 `/readyz` 或 gRPC health 通过。 +3. 把新实例加入对应 CLB 后端或内部 DNS。 +4. 观察错误率、延迟和日志。 +5. 从 CLB 摘除一个旧实例,等待 CLB 健康检查和连接排空周期。 +6. 对旧实例发送 `SIGTERM`。 +7. 旧实例进入 draining,gRPC 完成 in-flight 请求后释放已加载房间 lease,并删除 `room:node:{node_id}`。 +8. 新请求命中新节点后,如果 Redis route 已释放,会立即接管并从 MySQL 恢复;如果旧实例异常退出,则等待 `lease_ttl` 到期后接管。 + +发布约束: + +- 不要先杀进程再摘 CLB。 +- 不要把 `room-service` 的 `advertise_addr` 写成内网 CLB。 +- 不要多个实例复用同一个 `node_id`。 +- 数据库变更要先执行兼容当前发布窗口的迁移,再滚动服务。 + +## 配置拆分 + +不用统一配置中心也可以上线。推荐发布系统渲染最终 `config.yaml`: + +- 公共环境配置:MySQL/Redis 私网地址、日志等级、腾讯云 endpoint。 +- 服务配置:端口、worker 周期、批量大小、超时。 +- 实例配置:`node_id`、`advertise_addr`、`user-service.id_generator.node_id`。 +- Secret:MySQL/Redis 密码、JWT secret、腾讯 IM/RTC/COS secret、callback token。 + +只有当需要动态配置、灰度下发、审计回滚时,再引入 Consul/Nacos/Apollo。当前代码的启动配置仍以单服务 YAML 为边界。 + +## 真实数据验证 + +本地真实 MySQL/Redis 验证: + +```bash +docker compose up -d mysql redis +ROOM_SERVICE_MYSQL_TEST_DSN='hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC' \ +ROOM_SERVICE_REDIS_TEST_ADDR='127.0.0.1:13379' \ +go test -count=1 ./services/room-service/internal/transport/grpc -run TestOwnerForwardingUsesRedisRouteAndNodeRegistry +``` + +该测试会启动两个真实 gRPC `room-service` 实例,共用真实 MySQL schema 和 Redis route/node registry:在 node A 创建房间,通过 node B 调 `JoinRoom`、`CheckSpeakPermission` 和 `GetRoomSnapshot`,验证请求会转发到当前 owner。 diff --git a/docs/房间Cell状态机.md b/docs/房间Cell状态机.md index fccd75bf..c9e5a946 100644 --- a/docs/房间Cell状态机.md +++ b/docs/房间Cell状态机.md @@ -96,12 +96,15 @@ stateDiagram-v2 规则: - Redis runtime route key 统一使用 `app_code + "\0" + room_id`,包括默认 App;不能再使用裸 `room_id` 作为兼容 key。 +- `room:node:{node_id}` 保存当前 room-service 实例的 `advertise_addr`,只用于 owner 转发;它不是服务发现的全局配置中心。 +- gateway 可以连接 room-service 内网负载均衡;命中非 owner 节点时,gRPC 边界必须按 Redis owner route 转发到当前 owner 实例。 +- `advertise_addr` 必须是实例私网地址,不能是 room-service 内网 CLB 地址,否则转发会命中任意节点并触发递归保护。 - 命令开始前通过 `EnsureOwner` 获得执行权。 - 命令真正写 command log/outbox 前必须用 `lease_token` 再做一次 `VerifyOwner`。 - 如果提交前 lease 已经过期或 token 已变化,旧节点必须返回 `UNAVAILABLE`,不能写房间状态。 - `room_command_log` 保存 `owner_node_id` 和 `lease_token`,用于排查谁在什么 lease 下提交了命令。 - stale presence worker 和 mic publish timeout worker 只能扫描本节点已加载 Cell;清理前必须确认当前 Redis lease 仍属于本节点,并在读完快照后再次用相同 `lease_token` 校验,最后由命令提交链路再做 fencing。 -- 节点进入 draining 后不能再接收新的 Room Cell 命令,也不能恢复或创建新的 Cell。 +- 节点进入 draining 后不能再接收新的 Room Cell 命令,也不能恢复或创建新的 Cell;gRPC drain 完成后应主动释放本节点已加载房间的 owner lease,降低滚动发布等待 TTL 的窗口。 ## Outbox diff --git a/pkg/grpcclient/client.go b/pkg/grpcclient/client.go new file mode 100644 index 00000000..e7e47ce5 --- /dev/null +++ b/pkg/grpcclient/client.go @@ -0,0 +1,166 @@ +// Package grpcclient provides one production dial policy for internal gRPC +// clients. The policy keeps transport behavior out of business handlers. +package grpcclient + +import ( + "context" + "fmt" + "strings" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" +) + +// Config controls connection backoff, keepalive, retry and per-RPC deadlines. +type Config struct { + DefaultTimeout time.Duration + ConnectTimeout time.Duration + KeepaliveTime time.Duration + KeepaliveTimeout time.Duration + RetryMaxAttempts int + RetryInitialBackoff time.Duration + RetryMaxBackoff time.Duration + RetryBackoffMultiplier float64 + RetryableStatusCodes []string +} + +// DefaultConfig is conservative: retries are limited to one retry on +// UNAVAILABLE and must complete inside the caller's deadline. +func DefaultConfig() Config { + return Config{ + DefaultTimeout: 5 * time.Second, + ConnectTimeout: 2 * time.Second, + KeepaliveTime: 30 * time.Second, + KeepaliveTimeout: 10 * time.Second, + RetryMaxAttempts: 2, + RetryInitialBackoff: 100 * time.Millisecond, + RetryMaxBackoff: 500 * time.Millisecond, + RetryBackoffMultiplier: 2, + RetryableStatusCodes: []string{"UNAVAILABLE"}, + } +} + +// Normalize fills safe transport defaults and clamps retry attempts to grpc-go's +// supported range. A value below two disables explicit retry. +func Normalize(cfg Config) Config { + defaults := DefaultConfig() + if cfg.DefaultTimeout <= 0 { + cfg.DefaultTimeout = defaults.DefaultTimeout + } + if cfg.ConnectTimeout <= 0 { + cfg.ConnectTimeout = defaults.ConnectTimeout + } + if cfg.KeepaliveTime <= 0 { + cfg.KeepaliveTime = defaults.KeepaliveTime + } + if cfg.KeepaliveTimeout <= 0 { + cfg.KeepaliveTimeout = defaults.KeepaliveTimeout + } + if cfg.RetryMaxAttempts < 0 { + cfg.RetryMaxAttempts = 0 + } + if cfg.RetryMaxAttempts == 0 { + cfg.RetryMaxAttempts = defaults.RetryMaxAttempts + } + if cfg.RetryMaxAttempts > 5 { + cfg.RetryMaxAttempts = 5 + } + if cfg.RetryInitialBackoff <= 0 { + cfg.RetryInitialBackoff = defaults.RetryInitialBackoff + } + if cfg.RetryMaxBackoff <= 0 { + cfg.RetryMaxBackoff = defaults.RetryMaxBackoff + } + if cfg.RetryMaxBackoff < cfg.RetryInitialBackoff { + cfg.RetryMaxBackoff = cfg.RetryInitialBackoff + } + if cfg.RetryBackoffMultiplier <= 0 { + cfg.RetryBackoffMultiplier = defaults.RetryBackoffMultiplier + } + if len(cfg.RetryableStatusCodes) == 0 { + cfg.RetryableStatusCodes = defaults.RetryableStatusCodes + } + return cfg +} + +// Dial creates a ClientConn with deadlines, TCP reconnect backoff, keepalive and +// a minimal retry policy. Startup does not block on the remote endpoint; ready +// probes are responsible for proving the connection can become READY. +func Dial(target string, cfg Config, opts ...grpc.DialOption) (*grpc.ClientConn, error) { + cfg = Normalize(cfg) + dialOptions := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: 100 * time.Millisecond, + Multiplier: 1.6, + Jitter: 0.2, + MaxDelay: 5 * time.Second, + }, + MinConnectTimeout: cfg.ConnectTimeout, + }), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: cfg.KeepaliveTime, + Timeout: cfg.KeepaliveTimeout, + PermitWithoutStream: true, + }), + grpc.WithUnaryInterceptor(timeoutUnaryInterceptor(cfg.DefaultTimeout)), + } + if serviceConfig := defaultServiceConfig(cfg); serviceConfig != "" { + // The retry budget is intentionally transport-level and narrow. Business + // handlers still rely on command IDs/idempotency for mutation safety. + dialOptions = append(dialOptions, grpc.WithDefaultServiceConfig(serviceConfig)) + } + dialOptions = append(dialOptions, opts...) + return grpc.Dial(strings.TrimSpace(target), dialOptions...) +} + +func timeoutUnaryInterceptor(timeout time.Duration) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req any, reply any, conn *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if timeout > 0 { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + } + return invoker(ctx, method, req, reply, conn, opts...) + } +} + +func defaultServiceConfig(cfg Config) string { + if cfg.RetryMaxAttempts < 2 { + return `{"methodConfig":[{"name":[{}],"waitForReady":true}]}` + } + + return fmt.Sprintf( + `{"methodConfig":[{"name":[{}],"waitForReady":true,"retryPolicy":{"maxAttempts":%d,"initialBackoff":"%s","maxBackoff":"%s","backoffMultiplier":%.2f,"retryableStatusCodes":[%s]}}]}`, + cfg.RetryMaxAttempts, + durationString(cfg.RetryInitialBackoff), + durationString(cfg.RetryMaxBackoff), + cfg.RetryBackoffMultiplier, + statusCodeList(cfg.RetryableStatusCodes), + ) +} + +func durationString(value time.Duration) string { + return fmt.Sprintf("%.3fs", value.Seconds()) +} + +func statusCodeList(codes []string) string { + quoted := make([]string, 0, len(codes)) + for _, code := range codes { + code = strings.ToUpper(strings.TrimSpace(code)) + if code == "" { + continue + } + quoted = append(quoted, fmt.Sprintf("%q", code)) + } + if len(quoted) == 0 { + return `"UNAVAILABLE"` + } + return strings.Join(quoted, ",") +} diff --git a/pkg/grpcclient/client_test.go b/pkg/grpcclient/client_test.go new file mode 100644 index 00000000..d46fd4da --- /dev/null +++ b/pkg/grpcclient/client_test.go @@ -0,0 +1,52 @@ +package grpcclient + +import ( + "context" + "strings" + "testing" + "time" + + "google.golang.org/grpc" +) + +func TestNormalizeRestoresTransportDefaults(t *testing.T) { + cfg := Normalize(Config{}) + + if cfg.DefaultTimeout != 5*time.Second || cfg.ConnectTimeout != 2*time.Second { + t.Fatalf("deadline defaults mismatch: %+v", cfg) + } + if cfg.KeepaliveTime != 30*time.Second || cfg.KeepaliveTimeout != 10*time.Second { + t.Fatalf("keepalive defaults mismatch: %+v", cfg) + } + if cfg.RetryMaxAttempts != 2 || cfg.RetryInitialBackoff != 100*time.Millisecond || cfg.RetryMaxBackoff != 500*time.Millisecond { + t.Fatalf("retry defaults mismatch: %+v", cfg) + } +} + +func TestTimeoutInterceptorPreservesExistingDeadline(t *testing.T) { + parent, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + before, _ := parent.Deadline() + + interceptor := timeoutUnaryInterceptor(time.Millisecond) + err := interceptor(parent, "/svc/Method", nil, nil, nil, func(ctx context.Context, method string, req any, reply any, conn *grpc.ClientConn, opts ...grpc.CallOption) error { + after, ok := ctx.Deadline() + if !ok || !after.Equal(before) { + t.Fatalf("deadline should be preserved: ok=%t before=%s after=%s", ok, before, after) + } + return nil + }) + if err != nil { + t.Fatalf("interceptor returned error: %v", err) + } +} + +func TestDefaultServiceConfigIncludesUnavailableRetry(t *testing.T) { + serviceConfig := defaultServiceConfig(DefaultConfig()) + if !strings.Contains(serviceConfig, `"retryableStatusCodes":["UNAVAILABLE"]`) { + t.Fatalf("retry policy missing UNAVAILABLE: %s", serviceConfig) + } + if !strings.Contains(serviceConfig, `"waitForReady":true`) { + t.Fatalf("service config should enable waitForReady: %s", serviceConfig) + } +} diff --git a/pkg/grpchealth/health.go b/pkg/grpchealth/health.go index 2e7d45d7..eb3be088 100644 --- a/pkg/grpchealth/health.go +++ b/pkg/grpchealth/health.go @@ -19,9 +19,9 @@ const ( // Check 描述一项 readiness 子检查;gRPC health 只返回总状态,子项用于服务内聚合。 type Check struct { - Name string - Status string - Message string + Name string `json:"name"` + Status string `json:"status"` + Message string `json:"message,omitempty"` } // Checker 把服务自己的 ready 语义适配到标准 gRPC health protocol。 diff --git a/pkg/grpchealth/health_test.go b/pkg/grpchealth/health_test.go index 251c4031..e04474cf 100644 --- a/pkg/grpchealth/health_test.go +++ b/pkg/grpchealth/health_test.go @@ -2,6 +2,7 @@ package grpchealth import ( "context" + "errors" "testing" healthpb "google.golang.org/grpc/health/grpc_health_v1" @@ -37,3 +38,18 @@ func TestServingCheckerMapsReadiness(t *testing.T) { t.Fatalf("status after draining mismatch: got %s want NOT_SERVING", resp.GetStatus()) } } + +func TestServingCheckerDependencyFailureAffectsReady(t *testing.T) { + checker := NewServingChecker("wallet-service", Dependency{ + Name: "mysql", + Check: func(context.Context) error { + return errors.New("mysql down") + }, + }) + checker.MarkServing() + + checks := checker.Ready(context.Background()) + if Healthy(checks) { + t.Fatalf("dependency failure should make checker not ready: %+v", checks) + } +} diff --git a/pkg/grpchealth/serving.go b/pkg/grpchealth/serving.go index 48e14577..5e16dbb4 100644 --- a/pkg/grpchealth/serving.go +++ b/pkg/grpchealth/serving.go @@ -2,19 +2,31 @@ package grpchealth import ( "context" + "strings" "sync/atomic" + "time" ) +const defaultDependencyBudget = 300 * time.Millisecond + +// Dependency 是 readiness 中必须可用的外部依赖。 +type Dependency struct { + Name string + Budget time.Duration + Check func(ctx context.Context) error +} + // ServingChecker 覆盖无外部硬依赖的占位或 stub gRPC 服务 readiness。 type ServingChecker struct { - serviceName string - serving atomic.Bool - draining atomic.Bool + serviceName string + dependencies []Dependency + serving atomic.Bool + draining atomic.Bool } // NewServingChecker 创建只依赖 gRPC listener 和 draining 状态的 checker。 -func NewServingChecker(serviceName string) *ServingChecker { - return &ServingChecker{serviceName: serviceName} +func NewServingChecker(serviceName string, dependencies ...Dependency) *ServingChecker { + return &ServingChecker{serviceName: serviceName, dependencies: dependencies} } // ServiceName 返回标准 gRPC health 使用的服务边界名。 @@ -37,10 +49,35 @@ func (c *ServingChecker) MarkDraining() { c.draining.Store(true) } -// Ready 返回无数据库类硬依赖服务的最小 readiness。 -func (c *ServingChecker) Ready(_ context.Context) []Check { - return []Check{ +// Ready 返回 listener、draining 和外部硬依赖的聚合 readiness。 +func (c *ServingChecker) Ready(ctx context.Context) []Check { + checks := []Check{ Bool("grpc_listener", c.serving.Load(), "gRPC listener is not serving"), Bool("draining", !c.draining.Load(), "node is draining"), } + for _, dependency := range c.dependencies { + checks = append(checks, dependency.Ready(ctx)) + } + return checks +} + +// Ready executes a dependency check with a short bounded budget. +func (d Dependency) Ready(ctx context.Context) Check { + name := strings.TrimSpace(d.Name) + if name == "" { + name = "dependency" + } + if d.Check == nil { + return Fail(name, "dependency check is not configured") + } + budget := d.Budget + if budget <= 0 { + budget = defaultDependencyBudget + } + checkCtx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + if err := d.Check(checkCtx); err != nil { + return Fail(name, err.Error()) + } + return OK(name) } diff --git a/pkg/grpcshutdown/graceful.go b/pkg/grpcshutdown/graceful.go new file mode 100644 index 00000000..6d614584 --- /dev/null +++ b/pkg/grpcshutdown/graceful.go @@ -0,0 +1,34 @@ +// Package grpcshutdown centralizes gRPC draining semantics for all services. +package grpcshutdown + +import ( + "time" + + "google.golang.org/grpc" +) + +const defaultTimeout = 15 * time.Second + +// GracefulStop waits for in-flight RPCs to finish, then falls back to Stop so a +// wedged client stream cannot block deploy shutdown forever. +func GracefulStop(server *grpc.Server, timeout time.Duration) { + if server == nil { + return + } + if timeout <= 0 { + timeout = defaultTimeout + } + + done := make(chan struct{}) + go func() { + server.GracefulStop() + close(done) + }() + + select { + case <-done: + case <-time.After(timeout): + server.Stop() + <-done + } +} diff --git a/pkg/healthhttp/server.go b/pkg/healthhttp/server.go new file mode 100644 index 00000000..c2852aca --- /dev/null +++ b/pkg/healthhttp/server.go @@ -0,0 +1,134 @@ +// Package healthhttp exposes process-local HTTP health endpoints for services +// whose primary protocol is not HTTP. +package healthhttp + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "strings" + "sync" + "time" + + "hyapp/pkg/grpchealth" +) + +const ( + statusOK = "ok" + statusFail = "fail" +) + +// LiveChecker allows services with richer runtime liveness checks, such as +// room-service, to reuse their existing process probes. +type LiveChecker interface { + Live() []grpchealth.Check +} + +// Response is intentionally close to gateway health responses so CLB probes and +// deploy scripts can consume one stable shape across all services. +type Response struct { + Status string `json:"status"` + Service string `json:"service"` + NodeID string `json:"node_id"` + Checks []grpchealth.Check `json:"checks"` +} + +// Server owns the separate HTTP listener used by CLB health checks for gRPC +// processes. It does not replace gRPC health; it makes health visible to CLB. +type Server struct { + nodeID string + checker grpchealth.Checker + server *http.Server + listener net.Listener + once sync.Once +} + +// New creates a health HTTP server. Empty addr disables the HTTP health port so +// unit tests and special one-off tools can keep a single listener. +func New(addr string, nodeID string, checker grpchealth.Checker) (*Server, error) { + addr = strings.TrimSpace(addr) + if addr == "" { + return nil, nil + } + if checker == nil { + return nil, errors.New("health checker is required") + } + + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + + s := &Server{ + nodeID: strings.TrimSpace(nodeID), + checker: checker, + listener: listener, + } + mux := http.NewServeMux() + mux.HandleFunc("/healthz/live", s.live) + mux.HandleFunc("/healthz/ready", s.ready) + s.server = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 2 * time.Second, + } + + return s, nil +} + +// Run serves health endpoints until Close is called or the listener fails. +func (s *Server) Run() error { + if s == nil { + return nil + } + err := s.server.Serve(s.listener) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +// Close stops the health endpoint. Readiness should already be failing before +// this is called because the owning service marks its checker draining first. +func (s *Server) Close(ctx context.Context) error { + if s == nil { + return nil + } + var err error + s.once.Do(func() { + err = s.server.Shutdown(ctx) + }) + return err +} + +func (s *Server) live(writer http.ResponseWriter, request *http.Request) { + checks := []grpchealth.Check{grpchealth.OK("process")} + if live, ok := s.checker.(LiveChecker); ok { + // Services with process-local invariants should expose them on live, but + // live still avoids slow external dependency probes. + checks = live.Live() + } + s.write(writer, checks) +} + +func (s *Server) ready(writer http.ResponseWriter, request *http.Request) { + s.write(writer, s.checker.Ready(request.Context())) +} + +func (s *Server) write(writer http.ResponseWriter, checks []grpchealth.Check) { + status := statusFail + code := http.StatusServiceUnavailable + if grpchealth.Healthy(checks) { + status = statusOK + code = http.StatusOK + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(code) + _ = json.NewEncoder(writer).Encode(Response{ + Status: status, + Service: s.checker.ServiceName(), + NodeID: s.nodeID, + Checks: checks, + }) +} diff --git a/pkg/healthhttp/server_test.go b/pkg/healthhttp/server_test.go new file mode 100644 index 00000000..7b501d55 --- /dev/null +++ b/pkg/healthhttp/server_test.go @@ -0,0 +1,73 @@ +package healthhttp + +import ( + "encoding/json" + "net" + "net/http" + "testing" + + "hyapp/pkg/grpchealth" +) + +func TestHTTPReadyReflectsCheckerState(t *testing.T) { + checker := grpchealth.NewServingChecker("test-service") + server, err := New("127.0.0.1:0", "test-node", checker) + if err != nil { + t.Fatalf("New failed: %v", err) + } + go func() { + _ = server.Run() + }() + t.Cleanup(func() { + _ = server.Close(t.Context()) + }) + + baseURL := "http://" + server.listener.Addr().String() + resp, err := http.Get(baseURL + "/healthz/ready") + if err != nil { + t.Fatalf("GET not-ready failed: %v", err) + } + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("not-ready status mismatch: got %d", resp.StatusCode) + } + _ = resp.Body.Close() + + checker.MarkServing() + resp, err = http.Get(baseURL + "/healthz/ready") + if err != nil { + t.Fatalf("GET ready failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("ready status mismatch: got %d", resp.StatusCode) + } + var body Response + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if body.Service != "test-service" || body.NodeID != "test-node" || body.Status != "ok" { + t.Fatalf("unexpected response: %+v", body) + } +} + +func TestNewDisabledWhenAddrEmpty(t *testing.T) { + server, err := New("", "node", grpchealth.NewServingChecker("svc")) + if err != nil { + t.Fatalf("New empty addr failed: %v", err) + } + if server != nil { + t.Fatalf("empty addr should disable health HTTP server") + } +} + +func TestNewFailsWhenPortAlreadyBound(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + + if _, err := New(listener.Addr().String(), "node", grpchealth.NewServingChecker("svc")); err == nil { + t.Fatalf("New should fail on occupied health port") + } +} diff --git a/services/activity-service/Dockerfile b/services/activity-service/Dockerfile index c10ab51e..29b3f972 100644 --- a/services/activity-service/Dockerfile +++ b/services/activity-service/Dockerfile @@ -30,7 +30,7 @@ COPY services/activity-service/configs/config.docker.yaml /app/config.yaml USER appuser -EXPOSE 13006 +EXPOSE 13006 13106 ENTRYPOINT ["/app/server"] CMD ["-config", "/app/config.yaml"] diff --git a/services/activity-service/configs/config.docker.yaml b/services/activity-service/configs/config.docker.yaml index 81460f86..4ccf4380 100644 --- a/services/activity-service/configs/config.docker.yaml +++ b/services/activity-service/configs/config.docker.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13006" +health_http_addr: ":13106" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC" user_service_addr: "user-service:13005" wallet_service_addr: "wallet-service:13004" diff --git a/services/activity-service/configs/config.tencent.example.yaml b/services/activity-service/configs/config.tencent.example.yaml index 8ff3c2a4..aca87e64 100644 --- a/services/activity-service/configs/config.tencent.example.yaml +++ b/services/activity-service/configs/config.tencent.example.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13006" +health_http_addr: ":13106" mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC&tls=false" user_service_addr: "user-service.internal:13005" wallet_service_addr: "wallet-service.internal:13004" diff --git a/services/activity-service/configs/config.yaml b/services/activity-service/configs/config.yaml index f7ae2003..9e3c782e 100644 --- a/services/activity-service/configs/config.yaml +++ b/services/activity-service/configs/config.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13006" +health_http_addr: ":13106" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC" user_service_addr: "127.0.0.1:13005" wallet_service_addr: "127.0.0.1:13004" diff --git a/services/activity-service/internal/app/app.go b/services/activity-service/internal/app/app.go index 0bba861c..65bedd02 100644 --- a/services/activity-service/internal/app/app.go +++ b/services/activity-service/internal/app/app.go @@ -13,6 +13,8 @@ import ( activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/services/activity-service/internal/client" @@ -30,6 +32,7 @@ type App struct { server *grpc.Server listener net.Listener health *grpchealth.ServingChecker + healthHTTP *healthhttp.Server mysqlRepo *mysqlstorage.Repository broadcast *broadcastservice.Service broadcastWorkerEnabled bool @@ -111,14 +114,26 @@ func New(cfg config.Config) (*App, error) { broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc) activityv1.RegisterBroadcastServiceServer(server, broadcastServer) activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer) - health := grpchealth.NewServingChecker("activity-service") + health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{ + Name: "mysql", + Check: repository.Ping, + }) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + if err != nil { + _ = walletConn.Close() + _ = userConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } workerCtx, workerStop := context.WithCancel(context.Background()) return &App{ server: server, listener: listener, health: health, + healthHTTP: healthHTTP, mysqlRepo: repository, broadcast: broadcastSvc, broadcastWorkerEnabled: cfg.Broadcast.Enabled, @@ -131,6 +146,7 @@ func New(cfg config.Config) (*App, error) { // Run 启动 gRPC 服务。 func (a *App) Run() error { + a.runHealthHTTP() a.health.MarkServing() if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil { a.workerWG.Go(func() { @@ -158,7 +174,8 @@ func (a *App) Close() { a.workerStop() } a.workerWG.Wait() - a.server.GracefulStop() + grpcshutdown.GracefulStop(a.server, 15*time.Second) + a.closeHealthHTTP() if a.userConn != nil { _ = a.userConn.Close() } @@ -171,3 +188,23 @@ func (a *App) Close() { } }) } + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/activity-service/internal/config/config.go b/services/activity-service/internal/config/config.go index 7ae623f0..cf1c52b9 100644 --- a/services/activity-service/internal/config/config.go +++ b/services/activity-service/internal/config/config.go @@ -16,6 +16,8 @@ type Config struct { NodeID string `yaml:"node_id"` Environment string `yaml:"environment"` GRPCAddr string `yaml:"grpc_addr"` + // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 + HealthHTTPAddr string `yaml:"health_http_addr"` // MySQLDSN 是活动状态、事件消费幂等和 outbox 的必需存储连接串。 MySQLDSN string `yaml:"mysql_dsn"` // UserServiceAddr 是 fanout worker 读取目标用户游标的内部 gRPC 地址。 @@ -91,6 +93,7 @@ func Default() Config { NodeID: "activity-local", Environment: "local", GRPCAddr: ":13006", + HealthHTTPAddr: ":13106", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=UTC", UserServiceAddr: "127.0.0.1:13005", WalletServiceAddr: "127.0.0.1:13004", @@ -155,6 +158,10 @@ func Load(path string) (Config, error) { if cfg.Environment == "" { cfg.Environment = "local" } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13106" + } cfg.TencentIM.AdminIdentifier = strings.TrimSpace(cfg.TencentIM.AdminIdentifier) if cfg.TencentIM.AdminIdentifier == "" { cfg.TencentIM.AdminIdentifier = "administrator" diff --git a/services/cron-service/Dockerfile b/services/cron-service/Dockerfile index 60cac8e8..bdcdb908 100644 --- a/services/cron-service/Dockerfile +++ b/services/cron-service/Dockerfile @@ -30,7 +30,7 @@ COPY services/cron-service/configs/config.docker.yaml /app/config.yaml USER appuser -EXPOSE 13007 +EXPOSE 13007 13107 ENTRYPOINT ["/app/server"] CMD ["-config", "/app/config.yaml"] diff --git a/services/cron-service/configs/config.docker.yaml b/services/cron-service/configs/config.docker.yaml index 900cebf8..4cc77eec 100644 --- a/services/cron-service/configs/config.docker.yaml +++ b/services/cron-service/configs/config.docker.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13007" +health_http_addr: ":13107" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC" user_service_addr: "user-service:13005" activity_service_addr: "activity-service:13006" diff --git a/services/cron-service/configs/config.tencent.example.yaml b/services/cron-service/configs/config.tencent.example.yaml index 9da9f2c2..24eba97c 100644 --- a/services/cron-service/configs/config.tencent.example.yaml +++ b/services/cron-service/configs/config.tencent.example.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13007" +health_http_addr: ":13107" mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC&tls=false" user_service_addr: "user-service.internal:13005" activity_service_addr: "activity-service.internal:13006" diff --git a/services/cron-service/configs/config.yaml b/services/cron-service/configs/config.yaml index e7638048..90b9da1f 100644 --- a/services/cron-service/configs/config.yaml +++ b/services/cron-service/configs/config.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13007" +health_http_addr: ":13107" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC" user_service_addr: "127.0.0.1:13005" activity_service_addr: "127.0.0.1:13006" diff --git a/services/cron-service/internal/app/app.go b/services/cron-service/internal/app/app.go index a6dceb97..e4ed8d66 100644 --- a/services/cron-service/internal/app/app.go +++ b/services/cron-service/internal/app/app.go @@ -14,6 +14,8 @@ import ( activityv1 "hyapp.local/api/proto/activity/v1" userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/services/cron-service/internal/config" "hyapp/services/cron-service/internal/integration" @@ -26,6 +28,7 @@ type App struct { server *grpc.Server listener net.Listener health *grpchealth.ServingChecker + healthHTTP *healthhttp.Server repository *mysqlstorage.Repository scheduler *scheduler.Scheduler userConn *grpc.ClientConn @@ -67,8 +70,19 @@ func New(cfg config.Config) (*App, error) { } server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service"))) - health := grpchealth.NewServingChecker("cron-service") + health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{ + Name: "mysql", + Check: repository.Ping, + }) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + if err != nil { + _ = activityConn.Close() + _ = userConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } workerCtx, workerCancel := context.WithCancel(context.Background()) userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn)) @@ -84,6 +98,7 @@ func New(cfg config.Config) (*App, error) { server: server, listener: listener, health: health, + healthHTTP: healthHTTP, repository: repository, scheduler: taskScheduler, userConn: userConn, @@ -95,6 +110,7 @@ func New(cfg config.Config) (*App, error) { // Run starts scheduler loops and gRPC health serving. func (a *App) Run() error { + a.runHealthHTTP() a.health.MarkServing() defer func() { if a.workerCancel != nil { @@ -124,7 +140,8 @@ func (a *App) Close() { a.workerCancel() } a.workerWG.Wait() - a.server.GracefulStop() + grpcshutdown.GracefulStop(a.server, 15*time.Second) + a.closeHealthHTTP() if a.repository != nil { _ = a.repository.Close() } @@ -136,3 +153,23 @@ func (a *App) Close() { } }) } + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/cron-service/internal/config/config.go b/services/cron-service/internal/config/config.go index 8ff190b4..d3a1adf6 100644 --- a/services/cron-service/internal/config/config.go +++ b/services/cron-service/internal/config/config.go @@ -21,6 +21,8 @@ type Config struct { Environment string `yaml:"environment"` // GRPCAddr exposes gRPC health and future internal management APIs. GRPCAddr string `yaml:"grpc_addr"` + // HealthHTTPAddr exposes HTTP health for CLB and deploy scripts only. + HealthHTTPAddr string `yaml:"health_http_addr"` // MySQLDSN stores cron run history and schedule-level leases only. MySQLDSN string `yaml:"mysql_dsn"` // UserServiceAddr is the owner endpoint for user/auth/host batch tasks. @@ -62,6 +64,7 @@ func Default() Config { NodeID: "cron-local", Environment: "local", GRPCAddr: ":13007", + HealthHTTPAddr: ":13107", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_cron?parseTime=true&charset=utf8mb4&loc=UTC", UserServiceAddr: "127.0.0.1:13005", ActivityServiceAddr: "127.0.0.1:13006", @@ -105,6 +108,10 @@ func normalize(cfg *Config) { if cfg.GRPCAddr == "" { cfg.GRPCAddr = ":13007" } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13107" + } cfg.Tasks = normalizeTasks(cfg.Tasks) } diff --git a/services/game-service/Dockerfile b/services/game-service/Dockerfile index 3807e342..b5792377 100644 --- a/services/game-service/Dockerfile +++ b/services/game-service/Dockerfile @@ -20,5 +20,5 @@ COPY --from=builder /out/server /app/server COPY --from=builder /out/grpc-health-probe /app/grpc-health-probe COPY services/game-service/configs/config.docker.yaml /app/config.yaml USER appuser -EXPOSE 13008 +EXPOSE 13008 13108 ENTRYPOINT ["/app/server", "-config", "/app/config.yaml"] diff --git a/services/game-service/configs/config.docker.yaml b/services/game-service/configs/config.docker.yaml index a154f10e..6eb7c4b8 100644 --- a/services/game-service/configs/config.docker.yaml +++ b/services/game-service/configs/config.docker.yaml @@ -2,6 +2,7 @@ service_name: game-service node_id: game-docker environment: local grpc_addr: ":13008" +health_http_addr: ":13108" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true" wallet_service_addr: "wallet-service:13004" user_service_addr: "user-service:13005" diff --git a/services/game-service/configs/config.tencent.example.yaml b/services/game-service/configs/config.tencent.example.yaml index c6de0a84..b7f6df46 100644 --- a/services/game-service/configs/config.tencent.example.yaml +++ b/services/game-service/configs/config.tencent.example.yaml @@ -2,6 +2,7 @@ service_name: game-service node_id: game-prod-1 environment: prod grpc_addr: ":13008" +health_http_addr: ":13108" mysql_dsn: "${GAME_MYSQL_DSN}" wallet_service_addr: "wallet-service:13004" user_service_addr: "user-service:13005" diff --git a/services/game-service/configs/config.yaml b/services/game-service/configs/config.yaml index 67f4d457..03b9456e 100644 --- a/services/game-service/configs/config.yaml +++ b/services/game-service/configs/config.yaml @@ -2,6 +2,7 @@ service_name: game-service node_id: game-local environment: local grpc_addr: ":13008" +health_http_addr: ":13108" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true" wallet_service_addr: "127.0.0.1:13004" user_service_addr: "127.0.0.1:13005" diff --git a/services/game-service/internal/app/app.go b/services/game-service/internal/app/app.go index 34a34e0e..6dcaaf0e 100644 --- a/services/game-service/internal/app/app.go +++ b/services/game-service/internal/app/app.go @@ -12,6 +12,8 @@ import ( healthgrpc "google.golang.org/grpc/health/grpc_health_v1" gamev1 "hyapp.local/api/proto/game/v1" "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/services/game-service/internal/client" "hyapp/services/game-service/internal/config" @@ -25,6 +27,7 @@ type App struct { server *grpc.Server listener net.Listener health *grpchealth.ServingChecker + healthHTTP *healthhttp.Server repo *mysqlstorage.Repository walletConn *grpc.ClientConn userConn *grpc.ClientConn @@ -64,13 +67,25 @@ func New(cfg config.Config) (*App, error) { gamev1.RegisterGameAppServiceServer(server, transport) gamev1.RegisterGameCallbackServiceServer(server, transport) gamev1.RegisterGameAdminServiceServer(server, transport) - health := grpchealth.NewServingChecker("game-service") + health := grpchealth.NewServingChecker("game-service", grpchealth.Dependency{ + Name: "mysql", + Check: repo.Ping, + }) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + if err != nil { + _ = listener.Close() + _ = userConn.Close() + _ = walletConn.Close() + _ = repo.Close() + return nil, err + } - return &App{server: server, listener: listener, health: health, repo: repo, walletConn: walletConn, userConn: userConn}, nil + return &App{server: server, listener: listener, health: health, healthHTTP: healthHTTP, repo: repo, walletConn: walletConn, userConn: userConn}, nil } func (a *App) Run() error { + a.runHealthHTTP() a.health.MarkServing() err := a.server.Serve(a.listener) a.health.MarkStopped() @@ -83,7 +98,8 @@ func (a *App) Run() error { func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() - a.server.GracefulStop() + grpcshutdown.GracefulStop(a.server, 15*time.Second) + a.closeHealthHTTP() if a.walletConn != nil { _ = a.walletConn.Close() } @@ -95,3 +111,23 @@ func (a *App) Close() { } }) } + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/game-service/internal/config/config.go b/services/game-service/internal/config/config.go index 60958a6c..b05039ef 100644 --- a/services/game-service/internal/config/config.go +++ b/services/game-service/internal/config/config.go @@ -11,10 +11,12 @@ import ( // Config 描述 game-service 启动所需的最小依赖。 type Config struct { - ServiceName string `yaml:"service_name"` - NodeID string `yaml:"node_id"` - Environment string `yaml:"environment"` - GRPCAddr string `yaml:"grpc_addr"` + ServiceName string `yaml:"service_name"` + NodeID string `yaml:"node_id"` + Environment string `yaml:"environment"` + GRPCAddr string `yaml:"grpc_addr"` + // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 + HealthHTTPAddr string `yaml:"health_http_addr"` MySQLDSN string `yaml:"mysql_dsn"` WalletServiceAddr string `yaml:"wallet_service_addr"` UserServiceAddr string `yaml:"user_service_addr"` @@ -29,6 +31,7 @@ func Default() Config { NodeID: "game-local", Environment: "local", GRPCAddr: ":13008", + HealthHTTPAddr: ":13108", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true", WalletServiceAddr: "127.0.0.1:13004", UserServiceAddr: "127.0.0.1:13005", @@ -73,6 +76,10 @@ func (cfg *Config) Normalize() error { if cfg.GRPCAddr == "" { cfg.GRPCAddr = ":13008" } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13108" + } cfg.MySQLDSN = strings.TrimSpace(cfg.MySQLDSN) if cfg.MySQLDSN == "" { return fmt.Errorf("mysql_dsn is required") diff --git a/services/gateway-service/configs/config.docker.yaml b/services/gateway-service/configs/config.docker.yaml index 2282e3de..9349481b 100644 --- a/services/gateway-service/configs/config.docker.yaml +++ b/services/gateway-service/configs/config.docker.yaml @@ -14,6 +14,17 @@ user_service_addr: "user-service:13005" wallet_service_addr: "wallet-service:13004" activity_service_addr: "activity-service:13006" game_service_addr: "game-service:13008" +grpc_client: + # Docker 本地也使用生产同形态的 deadline、keepalive 和窄重试策略。 + default_timeout: "5s" + connect_timeout: "2s" + keepalive_time: "30s" + keepalive_timeout: "10s" + retry_max_attempts: 2 + retry_initial_backoff: "100ms" + retry_max_backoff: "500ms" + retry_backoff_multiplier: 2 + retryable_status_codes: ["UNAVAILABLE"] app_config: # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" diff --git a/services/gateway-service/configs/config.tencent.example.yaml b/services/gateway-service/configs/config.tencent.example.yaml index 1d67ce89..8d7b49b7 100644 --- a/services/gateway-service/configs/config.tencent.example.yaml +++ b/services/gateway-service/configs/config.tencent.example.yaml @@ -13,6 +13,18 @@ room_service_addr: "room-service.internal:13001" user_service_addr: "user-service.internal:13005" wallet_service_addr: "wallet-service.internal:13004" activity_service_addr: "activity-service.internal:13006" +game_service_addr: "game-service.internal:13008" +grpc_client: + # 线上通过内网 CLB/DNS 访问内部服务;重试只覆盖瞬时 UNAVAILABLE,避免放大业务副作用。 + default_timeout: "5s" + connect_timeout: "2s" + keepalive_time: "30s" + keepalive_timeout: "10s" + retry_max_attempts: 2 + retry_initial_backoff: "100ms" + retry_max_backoff: "500ms" + retry_backoff_multiplier: 2 + retryable_status_codes: ["UNAVAILABLE"] app_config: # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 mysql_dsn: "TENCENT_MYSQL_USER:TENCENT_MYSQL_PASSWORD@tcp(TENCENT_MYSQL_HOST:3306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" diff --git a/services/gateway-service/configs/config.yaml b/services/gateway-service/configs/config.yaml index 3cea4fb2..ce582b89 100644 --- a/services/gateway-service/configs/config.yaml +++ b/services/gateway-service/configs/config.yaml @@ -14,6 +14,17 @@ user_service_addr: "127.0.0.1:13005" wallet_service_addr: "127.0.0.1:13004" activity_service_addr: "127.0.0.1:13006" game_service_addr: "127.0.0.1:13008" +grpc_client: + # gateway 内部 gRPC 默认 deadline 包含一次 UNAVAILABLE 重试;业务长调用应在 handler 上下文显式收紧或放宽。 + default_timeout: "5s" + connect_timeout: "2s" + keepalive_time: "30s" + keepalive_timeout: "10s" + retry_max_attempts: 2 + retry_initial_backoff: "100ms" + retry_max_backoff: "500ms" + retry_backoff_multiplier: 2 + retryable_status_codes: ["UNAVAILABLE"] app_config: # H5 地址由后台 APP配置/H5配置 写入 hyapp_admin.admin_app_configs,gateway 只读下发给 App。 mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC" diff --git a/services/gateway-service/internal/app/app.go b/services/gateway-service/internal/app/app.go index 94fc0023..09c96fe6 100644 --- a/services/gateway-service/internal/app/app.go +++ b/services/gateway-service/internal/app/app.go @@ -12,7 +12,7 @@ import ( "time" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" + "hyapp/pkg/grpcclient" "hyapp/pkg/tencentcos" "hyapp/services/gateway-service/internal/appconfig" "hyapp/services/gateway-service/internal/auth" @@ -43,31 +43,32 @@ func New(cfg config.Config) (*App, error) { return nil, err } - roomConn, err := grpc.Dial(cfg.RoomServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + grpcConfig := cfg.GRPCClient.Runtime() + roomConn, err := grpcclient.Dial(cfg.RoomServiceAddr, grpcConfig) if err != nil { return nil, err } - userConn, err := grpc.Dial(cfg.UserServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + userConn, err := grpcclient.Dial(cfg.UserServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() return nil, err } - walletConn, err := grpc.Dial(cfg.WalletServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + walletConn, err := grpcclient.Dial(cfg.WalletServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() return nil, err } - activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + activityConn, err := grpcclient.Dial(cfg.ActivityServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() _ = walletConn.Close() return nil, err } - gameConn, err := grpc.Dial(cfg.GameServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + gameConn, err := grpcclient.Dial(cfg.GameServiceAddr, grpcConfig) if err != nil { _ = roomConn.Close() _ = userConn.Close() @@ -203,7 +204,12 @@ func New(cfg config.Config) (*App, error) { } redisClose = joinClose(redisClose, loginRiskRedisClose) verifier := auth.NewVerifier(cfg.JWTSecret) - healthState := healthcheck.NewState(nodeID(), cfg.JWTSecret, roomConn) + healthState := healthcheck.NewState(nodeID(), cfg.JWTSecret, roomConn, + healthcheck.GRPCDependency{Name: "user_grpc", Conn: userConn}, + healthcheck.GRPCDependency{Name: "wallet_grpc", Conn: walletConn}, + healthcheck.GRPCDependency{Name: "activity_grpc", Conn: activityConn}, + healthcheck.GRPCDependency{Name: "game_grpc", Conn: gameConn}, + ) mux := http.NewServeMux() healthHandler := healthcheck.NewHTTPHandler(healthState) @@ -328,6 +334,14 @@ func (a *App) Close() error { var err error a.closeOnce.Do(func() { a.health.MarkDraining() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + // HTTP Shutdown drains accepted requests before client connections close, + // so in-flight handlers can finish their internal gRPC calls. + if shutdownErr := a.server.Shutdown(shutdownCtx); shutdownErr != nil { + // 超过 drain 预算后强制关闭,避免发布脚本被异常长连接卡住。 + err = errors.Join(err, shutdownErr, a.server.Close()) + } if a.roomConn != nil { err = errors.Join(err, a.roomConn.Close()) } @@ -349,8 +363,6 @@ func (a *App) Close() error { if a.redisClose != nil { err = errors.Join(err, a.redisClose()) } - - err = errors.Join(err, a.server.Close()) }) return err diff --git a/services/gateway-service/internal/config/config.go b/services/gateway-service/internal/config/config.go index 72f63d0d..cf1d0cc8 100644 --- a/services/gateway-service/internal/config/config.go +++ b/services/gateway-service/internal/config/config.go @@ -6,6 +6,7 @@ import ( "time" "hyapp/pkg/configx" + "hyapp/pkg/grpcclient" "hyapp/pkg/logx" "hyapp/pkg/tencentrtc" ) @@ -22,6 +23,7 @@ type Config struct { WalletServiceAddr string `yaml:"wallet_service_addr"` ActivityServiceAddr string `yaml:"activity_service_addr"` GameServiceAddr string `yaml:"game_service_addr"` + GRPCClient GRPCClientConfig `yaml:"grpc_client"` AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"` LoginRisk LoginRiskConfig `yaml:"login_risk"` AppConfig AppConfigConfig `yaml:"app_config"` @@ -31,6 +33,43 @@ type Config struct { Log logx.Config `yaml:"log"` } +// GRPCClientConfig 控制 gateway 访问内部服务的 gRPC transport 策略。 +type GRPCClientConfig struct { + // DefaultTimeout 是没有显式 request deadline 时的 RPC 总预算,包含重试耗时。 + DefaultTimeout time.Duration `yaml:"default_timeout"` + // ConnectTimeout 是单次建连最小预算,避免内网目标不可达时长期卡住。 + ConnectTimeout time.Duration `yaml:"connect_timeout"` + // KeepaliveTime 是空闲连接探活周期,帮助 CLB/NAT 后的半开连接尽早发现。 + KeepaliveTime time.Duration `yaml:"keepalive_time"` + // KeepaliveTimeout 是 keepalive ack 等待时间。 + KeepaliveTimeout time.Duration `yaml:"keepalive_timeout"` + // RetryMaxAttempts 是包含首次调用在内的最大尝试次数;小于 2 时关闭显式重试。 + RetryMaxAttempts int `yaml:"retry_max_attempts"` + // RetryInitialBackoff 是第一次重试前等待时间。 + RetryInitialBackoff time.Duration `yaml:"retry_initial_backoff"` + // RetryMaxBackoff 是重试退避上限。 + RetryMaxBackoff time.Duration `yaml:"retry_max_backoff"` + // RetryBackoffMultiplier 控制退避增长倍率。 + RetryBackoffMultiplier float64 `yaml:"retry_backoff_multiplier"` + // RetryableStatusCodes 限定可重试状态,生产默认只允许 UNAVAILABLE。 + RetryableStatusCodes []string `yaml:"retryable_status_codes"` +} + +// Runtime 转换为共享 gRPC client 配置,避免 app 层重复维护默认值。 +func (c GRPCClientConfig) Runtime() grpcclient.Config { + return grpcclient.Normalize(grpcclient.Config{ + DefaultTimeout: c.DefaultTimeout, + ConnectTimeout: c.ConnectTimeout, + KeepaliveTime: c.KeepaliveTime, + KeepaliveTimeout: c.KeepaliveTimeout, + RetryMaxAttempts: c.RetryMaxAttempts, + RetryInitialBackoff: c.RetryInitialBackoff, + RetryMaxBackoff: c.RetryMaxBackoff, + RetryBackoffMultiplier: c.RetryBackoffMultiplier, + RetryableStatusCodes: c.RetryableStatusCodes, + }) +} + // LoginRiskConfig 控制 gateway 登录入口快拦和 Redis 风控缓存读取。 type LoginRiskConfig struct { Enabled bool `yaml:"enabled"` @@ -147,6 +186,17 @@ func Default() Config { WalletServiceAddr: "127.0.0.1:13004", ActivityServiceAddr: "127.0.0.1:13006", GameServiceAddr: "127.0.0.1:13008", + GRPCClient: GRPCClientConfig{ + DefaultTimeout: 5 * time.Second, + ConnectTimeout: 2 * time.Second, + KeepaliveTime: 30 * time.Second, + KeepaliveTimeout: 10 * time.Second, + RetryMaxAttempts: 2, + RetryInitialBackoff: 100 * time.Millisecond, + RetryMaxBackoff: 500 * time.Millisecond, + RetryBackoffMultiplier: 2, + RetryableStatusCodes: []string{"UNAVAILABLE"}, + }, AppConfig: AppConfigConfig{ MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=UTC", }, @@ -234,6 +284,18 @@ func (cfg *Config) Normalize() error { if cfg.GameServiceAddr == "" { cfg.GameServiceAddr = "127.0.0.1:13008" } + runtimeGRPC := cfg.GRPCClient.Runtime() + cfg.GRPCClient = GRPCClientConfig{ + DefaultTimeout: runtimeGRPC.DefaultTimeout, + ConnectTimeout: runtimeGRPC.ConnectTimeout, + KeepaliveTime: runtimeGRPC.KeepaliveTime, + KeepaliveTimeout: runtimeGRPC.KeepaliveTimeout, + RetryMaxAttempts: runtimeGRPC.RetryMaxAttempts, + RetryInitialBackoff: runtimeGRPC.RetryInitialBackoff, + RetryMaxBackoff: runtimeGRPC.RetryMaxBackoff, + RetryBackoffMultiplier: runtimeGRPC.RetryBackoffMultiplier, + RetryableStatusCodes: runtimeGRPC.RetryableStatusCodes, + } cfg.AppConfig.MySQLDSN = strings.TrimSpace(cfg.AppConfig.MySQLDSN) cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr) cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix) diff --git a/services/gateway-service/internal/config/config_test.go b/services/gateway-service/internal/config/config_test.go index 19a7ed8f..417b3002 100644 --- a/services/gateway-service/internal/config/config_test.go +++ b/services/gateway-service/internal/config/config_test.go @@ -22,6 +22,9 @@ func TestLoad(t *testing.T) { if cfg.RoomServiceAddr != "127.0.0.1:13001" { t.Fatalf("unexpected room addr: %s", cfg.RoomServiceAddr) } + if cfg.GRPCClient.DefaultTimeout != 5*time.Second || cfg.GRPCClient.RetryMaxAttempts != 2 || len(cfg.GRPCClient.RetryableStatusCodes) != 1 || cfg.GRPCClient.RetryableStatusCodes[0] != "UNAVAILABLE" { + t.Fatalf("unexpected grpc client policy: %+v", cfg.GRPCClient) + } if cfg.AppConfig.MySQLDSN == "" || !strings.Contains(cfg.AppConfig.MySQLDSN, "hyapp_admin") { t.Fatalf("gateway app config mysql dsn must point to admin config db: %+v", cfg.AppConfig) } diff --git a/services/gateway-service/internal/healthcheck/checker.go b/services/gateway-service/internal/healthcheck/checker.go index 15e294a0..9420ab0b 100644 --- a/services/gateway-service/internal/healthcheck/checker.go +++ b/services/gateway-service/internal/healthcheck/checker.go @@ -15,8 +15,8 @@ import ( const ( serviceName = "gateway-service" - readyBudget = 500 * time.Millisecond - roomGRPCBudget = 300 * time.Millisecond + readyBudget = 2 * time.Second + grpcCheckBudget = 500 * time.Millisecond componentStatus = "ok" failStatus = "fail" ) @@ -28,22 +28,32 @@ type Check struct { Message string `json:"message,omitempty"` } +// GRPCDependency describes one internal service connection required before the +// gateway should receive new external traffic. +type GRPCDependency struct { + Name string + Conn *grpc.ClientConn +} + // State 记录 gateway 接新流量所需的进程状态和 room-service 连接状态。 type State struct { - nodeID string - jwtSecret string - room *grpc.ClientConn + nodeID string + jwtSecret string + dependencies []GRPCDependency httpServing atomic.Bool draining atomic.Bool } // NewState 创建 gateway 健康状态所有者。 -func NewState(nodeID string, jwtSecret string, room *grpc.ClientConn) *State { +func NewState(nodeID string, jwtSecret string, room *grpc.ClientConn, dependencies ...GRPCDependency) *State { + all := make([]GRPCDependency, 0, 1+len(dependencies)) + all = append(all, GRPCDependency{Name: "room_grpc", Conn: room}) + all = append(all, dependencies...) return &State{ - nodeID: nodeID, - jwtSecret: jwtSecret, - room: room, + nodeID: nodeID, + jwtSecret: jwtSecret, + dependencies: all, } } @@ -90,13 +100,7 @@ func (s *State) Ready(ctx context.Context) []Check { boolCheck("draining", !s.draining.Load(), "node is draining"), } - roomCtx, roomCancel := context.WithTimeout(ctx, roomGRPCBudget) - defer roomCancel() - if err := waitGRPCReady(roomCtx, s.room); err != nil { - checks = append(checks, failCheck("room_grpc", err.Error())) - } else { - checks = append(checks, okCheck("room_grpc")) - } + checks = append(checks, s.grpcChecks(ctx)...) return checks } @@ -112,6 +116,36 @@ func Healthy(checks []Check) bool { return true } +func (s *State) grpcChecks(ctx context.Context) []Check { + results := make([]Check, len(s.dependencies)) + done := make(chan struct{}) + + for index, dependency := range s.dependencies { + index := index + dependency := dependency + go func() { + name := strings.TrimSpace(dependency.Name) + if name == "" { + name = "grpc_dependency" + } + + checkCtx, cancel := context.WithTimeout(ctx, grpcCheckBudget) + defer cancel() + if err := waitGRPCReady(checkCtx, dependency.Conn); err != nil { + results[index] = failCheck(name, err.Error()) + } else { + results[index] = okCheck(name) + } + done <- struct{}{} + }() + } + + for range s.dependencies { + <-done + } + return results +} + func waitGRPCReady(ctx context.Context, conn *grpc.ClientConn) error { if conn == nil { return errors.New("room gRPC connection is not initialized") diff --git a/services/room-service/Dockerfile b/services/room-service/Dockerfile index 036dbbdd..501332a5 100644 --- a/services/room-service/Dockerfile +++ b/services/room-service/Dockerfile @@ -30,7 +30,7 @@ COPY services/room-service/configs/config.docker.yaml /app/config.yaml USER appuser -EXPOSE 13001 +EXPOSE 13001 13101 ENTRYPOINT ["/app/server"] CMD ["-config", "/app/config.yaml"] diff --git a/services/room-service/configs/config.docker.yaml b/services/room-service/configs/config.docker.yaml index d6cc94b5..106ec750 100644 --- a/services/room-service/configs/config.docker.yaml +++ b/services/room-service/configs/config.docker.yaml @@ -8,6 +8,11 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13001" +advertise_addr: "room-service:13001" +health_http_addr: ":13101" +node_registry_ttl: "30s" +node_registry_heartbeat_interval: "10s" +owner_forward_timeout: "2s" lease_ttl: "10s" rank_limit: 20 snapshot_every_n: 10 diff --git a/services/room-service/configs/config.tencent.example.yaml b/services/room-service/configs/config.tencent.example.yaml index a7b0e147..a3f3832e 100644 --- a/services/room-service/configs/config.tencent.example.yaml +++ b/services/room-service/configs/config.tencent.example.yaml @@ -8,6 +8,11 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13001" +advertise_addr: "ROOM_NODE_PRIVATE_IP_OR_DNS:13001" +health_http_addr: ":13101" +node_registry_ttl: "30s" +node_registry_heartbeat_interval: "10s" +owner_forward_timeout: "2s" lease_ttl: "10s" rank_limit: 20 snapshot_every_n: 10 diff --git a/services/room-service/configs/config.yaml b/services/room-service/configs/config.yaml index b072cb16..3954b25e 100644 --- a/services/room-service/configs/config.yaml +++ b/services/room-service/configs/config.yaml @@ -11,6 +11,11 @@ lease_ttl: "10s" rank_limit: 20 snapshot_every_n: 10 grpc_addr: ":13001" +advertise_addr: "127.0.0.1:13001" +health_http_addr: ":13101" +node_registry_ttl: "30s" +node_registry_heartbeat_interval: "10s" +owner_forward_timeout: "2s" wallet_service_addr: "127.0.0.1:13004" activity_service_addr: "127.0.0.1:13006" tencent_im: diff --git a/services/room-service/internal/app/app.go b/services/room-service/internal/app/app.go index dd850f2c..783de5c9 100644 --- a/services/room-service/internal/app/app.go +++ b/services/room-service/internal/app/app.go @@ -4,6 +4,7 @@ package app import ( "context" "errors" + "log/slog" "net" "sync" "time" @@ -14,6 +15,8 @@ import ( activityv1 "hyapp.local/api/proto/activity/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/services/room-service/internal/config" @@ -43,8 +46,12 @@ type App struct { repository *mysqlstorage.Repository // redisClose 关闭 Redis client,Redis directory 本身不拥有额外生命周期。 redisClose func() error + // nodeRegistry 保存当前节点可被 owner 转发直连的实例地址。 + nodeRegistry router.NodeRegistry // health 汇总 gRPC、runtime、MySQL 和 Redis lease 探测状态。 health *healthcheck.State + // healthHTTP 给 CLB 和发布脚本提供 HTTP readiness,不承载业务请求。 + healthHTTP *healthhttp.Server // workerCtx 统一控制本节点后台 worker,关闭时必须先停止 worker 再释放 MySQL/Redis。 workerCtx context.Context // workerCancel 停止 presence stale 和 outbox 补偿 worker。 @@ -135,16 +142,26 @@ func New(cfg config.Config) (*App, error) { }, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher) server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service"))) + roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout)) // 命令服务处理房间状态变更,守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。 - roomv1.RegisterRoomCommandServiceServer(server, grpcserver.NewServer(svc)) - roomv1.RegisterRoomGuardServiceServer(server, grpcserver.NewServer(svc)) - roomv1.RegisterRoomQueryServiceServer(server, grpcserver.NewServer(svc)) + roomv1.RegisterRoomCommandServiceServer(server, roomServer) + roomv1.RegisterRoomGuardServiceServer(server, roomServer) + roomv1.RegisterRoomQueryServiceServer(server, roomServer) healthState := healthcheck.NewState(cfg.NodeID, svc, repository, directory) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState) + if err != nil { + _ = activityConn.Close() + _ = walletConn.Close() + _ = repository.Close() + _ = redisClient.Close() + return nil, err + } // listener 在 New 阶段创建,可以把端口占用作为启动失败暴露。 listener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { + _ = healthHTTP.Close(context.Background()) _ = activityConn.Close() _ = walletConn.Close() _ = repository.Close() @@ -162,7 +179,9 @@ func New(cfg config.Config) (*App, error) { activityConn: activityConn, repository: repository, redisClose: redisClient.Close, + nodeRegistry: directory, health: healthState, + healthHTTP: healthHTTP, workerCtx: workerCtx, workerCancel: workerCancel, }, nil @@ -170,14 +189,22 @@ func New(cfg config.Config) (*App, error) { // Run 启动 gRPC 服务。 func (a *App) Run() error { + a.runHealthHTTP() // health 先标记 serving,再进入 Serve,ready 才能在监听开始后通过。 a.health.MarkGRPCServing() + if err := a.registerRoomNode(context.Background()); err != nil { + a.health.MarkGRPCStopped() + return err + } defer func() { if a.workerCancel != nil { a.workerCancel() } a.workerWG.Wait() }() + a.workerWG.Go(func() { + a.runNodeRegistryHeartbeat(a.workerCtx) + }) if a.cfg.PresenceStaleScanInterval > 0 { // presence worker 只清理本节点已装载 Cell,命令仍走 Room Cell 持久化链路。 a.workerWG.Go(func() { @@ -228,7 +255,15 @@ func (a *App) Close() { } a.workerWG.Wait() // GracefulStop 让已进入的 gRPC 请求自然结束。 - a.grpcServer.GracefulStop() + grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second) + if a.service != nil { + // gRPC drain 完成后释放本节点持有的 room route,降低滚动发布时等待 Redis TTL 的窗口。 + releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second) + a.service.ReleaseLoadedRoomLeases(releaseCtx) + releaseCancel() + } + a.unregisterRoomNode() + a.closeHealthHTTP() if a.walletConn != nil { _ = a.walletConn.Close() } @@ -245,3 +280,74 @@ func (a *App) Close() { } }) } + +func (a *App) registerRoomNode(ctx context.Context) error { + if a.nodeRegistry == nil { + return nil + } + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 2*time.Second) + defer cancel() + } + return a.nodeRegistry.RegisterNode(ctx, router.NodeRegistration{ + NodeID: a.cfg.NodeID, + GRPCAddr: a.cfg.AdvertiseAddr, + }, a.cfg.NodeRegistryTTL) +} + +func (a *App) runNodeRegistryHeartbeat(ctx context.Context) { + if a.nodeRegistry == nil { + return + } + ticker := time.NewTicker(a.cfg.NodeRegistryHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := a.registerRoomNode(ctx); err != nil { + logx.Warn(ctx, "room_node_register_failed", + slog.String("node_id", a.cfg.NodeID), + slog.String("advertise_addr", a.cfg.AdvertiseAddr), + slog.String("error", err.Error()), + ) + } + } + } +} + +func (a *App) unregisterRoomNode() { + if a.nodeRegistry == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := a.nodeRegistry.UnregisterNode(ctx, a.cfg.NodeID); err != nil { + logx.Warn(ctx, "room_node_unregister_failed", + slog.String("node_id", a.cfg.NodeID), + slog.String("error", err.Error()), + ) + } +} + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/room-service/internal/config/config.go b/services/room-service/internal/config/config.go index f04c6c3b..71c6aff3 100644 --- a/services/room-service/internal/config/config.go +++ b/services/room-service/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" + "net" "net/http" "strings" "time" @@ -22,6 +23,16 @@ type Config struct { NodeID string `yaml:"node_id"` // GRPCAddr 是内部 gRPC 监听地址,gateway-service 通过它访问 room-service。 GRPCAddr string `yaml:"grpc_addr"` + // AdvertiseAddr 是其他 room-service 实例转发到本节点时使用的私网地址,不能写负载均衡地址。 + AdvertiseAddr string `yaml:"advertise_addr"` + // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 + HealthHTTPAddr string `yaml:"health_http_addr"` + // NodeRegistryTTL 是 node_id -> advertise_addr 注册的有效期。 + NodeRegistryTTL time.Duration `yaml:"node_registry_ttl"` + // NodeRegistryHeartbeatInterval 是刷新节点注册的周期。 + NodeRegistryHeartbeatInterval time.Duration `yaml:"node_registry_heartbeat_interval"` + // OwnerForwardTimeout 是非 owner 节点转发到当前 owner 的单次 RPC 超时。 + OwnerForwardTimeout time.Duration `yaml:"owner_forward_timeout"` // LeaseTTL 是 room owner 租约有效期,节点故障后必须等它过期才能接管。 LeaseTTL time.Duration `yaml:"lease_ttl"` // RankLimit 是房间本地礼物榜 top N。 @@ -129,6 +140,10 @@ func Default() Config { Environment: "local", NodeID: "room-node-local", GRPCAddr: ":13001", + AdvertiseAddr: "127.0.0.1:13001", + HealthHTTPAddr: ":13101", + NodeRegistryTTL: 30 * time.Second, + OwnerForwardTimeout: 2 * time.Second, LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 10, @@ -190,6 +205,33 @@ func Normalize(cfg Config) (Config, error) { if cfg.NodeID == "" { cfg.NodeID = cfg.ServiceName } + cfg.GRPCAddr = strings.TrimSpace(cfg.GRPCAddr) + if cfg.GRPCAddr == "" { + cfg.GRPCAddr = ":13001" + } + cfg.AdvertiseAddr = strings.TrimSpace(cfg.AdvertiseAddr) + if cfg.AdvertiseAddr == "" { + cfg.AdvertiseAddr = defaultAdvertiseAddr(cfg.GRPCAddr) + } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13101" + } + if cfg.NodeRegistryTTL <= 0 { + cfg.NodeRegistryTTL = 30 * time.Second + } + if cfg.NodeRegistryHeartbeatInterval <= 0 { + cfg.NodeRegistryHeartbeatInterval = cfg.NodeRegistryTTL / 3 + } + if cfg.NodeRegistryHeartbeatInterval <= 0 { + cfg.NodeRegistryHeartbeatInterval = 10 * time.Second + } + if cfg.NodeRegistryHeartbeatInterval >= cfg.NodeRegistryTTL { + cfg.NodeRegistryHeartbeatInterval = cfg.NodeRegistryTTL / 3 + } + if cfg.OwnerForwardTimeout <= 0 { + cfg.OwnerForwardTimeout = 2 * time.Second + } cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr) if cfg.WalletServiceAddr == "" { cfg.WalletServiceAddr = "127.0.0.1:13004" @@ -207,6 +249,19 @@ func Normalize(cfg Config) (Config, error) { return cfg, nil } +func defaultAdvertiseAddr(grpcAddr string) string { + host, port, err := net.SplitHostPort(grpcAddr) + if err != nil { + // 非 host:port 形态保持原值,由启动拨测暴露错误。 + return grpcAddr + } + if host == "" || host == "0.0.0.0" || host == "::" { + // 本地默认值只能服务单机开发;多机部署必须显式配置真实私网 IP 或主机名。 + host = "127.0.0.1" + } + return net.JoinHostPort(host, port) +} + func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, error) { // Outbox worker 的所有时间和批量参数都必须有安全下限,避免配置缺省导致 busy loop。 defaults := defaultOutboxWorkerConfig() diff --git a/services/room-service/internal/config/config_test.go b/services/room-service/internal/config/config_test.go index b28c3c51..96e90842 100644 --- a/services/room-service/internal/config/config_test.go +++ b/services/room-service/internal/config/config_test.go @@ -14,10 +14,16 @@ func TestLoad(t *testing.T) { t.Fatalf("Load failed: %v", err) } - // room-service 默认只暴露 gRPC,端口必须保持 13xxx 约束。 + // room-service 业务只走 gRPC,HTTP health 仅服务 CLB 探活,端口必须保持 13xxx 约束。 if cfg.GRPCAddr != ":13001" { t.Fatalf("unexpected grpc addr: %s", cfg.GRPCAddr) } + if cfg.HealthHTTPAddr != ":13101" { + t.Fatalf("unexpected health http addr: %s", cfg.HealthHTTPAddr) + } + if cfg.AdvertiseAddr != "127.0.0.1:13001" || cfg.NodeRegistryTTL != 30*time.Second || cfg.NodeRegistryHeartbeatInterval != 10*time.Second || cfg.OwnerForwardTimeout != 2*time.Second { + t.Fatalf("unexpected owner routing config: advertise=%s ttl=%s heartbeat=%s timeout=%s", cfg.AdvertiseAddr, cfg.NodeRegistryTTL, cfg.NodeRegistryHeartbeatInterval, cfg.OwnerForwardTimeout) + } // lease TTL 是故障接管抖动窗口的基础参数。 if cfg.LeaseTTL != 10*time.Second { @@ -55,6 +61,9 @@ func TestLoadTencentExample(t *testing.T) { if cfg.TencentIM.RequestTimeout != 5*time.Second { t.Fatalf("unexpected request timeout: %s", cfg.TencentIM.RequestTimeout) } + if cfg.AdvertiseAddr == "" || cfg.AdvertiseAddr == "room-service.internal:13001" { + t.Fatalf("tencent example must use per-instance advertise_addr, got %q", cfg.AdvertiseAddr) + } if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 { t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) } diff --git a/services/room-service/internal/room/service/helpers.go b/services/room-service/internal/room/service/helpers.go index 770779bb..2cfda7f1 100644 --- a/services/room-service/internal/room/service/helpers.go +++ b/services/room-service/internal/room/service/helpers.go @@ -53,6 +53,12 @@ func runtimeRoomKey(appCode string, roomID string) string { return appCode + "\x00" + roomID } +// RuntimeRoomKey 返回 Redis owner lease 使用的运行时路由 key。 +// transport 层 owner 转发必须和领域层 EnsureOwner 使用同一个 key,否则会出现同一房间多 owner。 +func RuntimeRoomKey(appCode string, roomID string) string { + return runtimeRoomKey(appCode, roomID) +} + func runtimeRoomKeyFromContext(ctx context.Context, roomID string) string { return runtimeRoomKey(appcode.FromContext(ctx), roomID) } diff --git a/services/room-service/internal/room/service/lease_release.go b/services/room-service/internal/room/service/lease_release.go new file mode 100644 index 00000000..414feb32 --- /dev/null +++ b/services/room-service/internal/room/service/lease_release.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + "log/slog" + + "hyapp/pkg/logx" +) + +// ReleaseLoadedRoomLeases 释放当前节点仍持有的已加载房间 lease。 +// 该方法只应在 gRPC 已停止接新请求并完成 in-flight drain 后调用;提前释放会破坏单房间单 owner。 +func (s *Service) ReleaseLoadedRoomLeases(ctx context.Context) { + if s == nil || s.directory == nil { + return + } + for _, roomRef := range s.loadedRoomRefs() { + released, err := s.directory.ReleaseOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID) + if err != nil { + logx.Warn(ctx, "room_lease_release_failed", + slog.String("node_id", s.nodeID), + slog.String("app_code", roomRef.AppCode), + slog.String("room_id", roomRef.RoomID), + slog.String("error", err.Error()), + ) + continue + } + if released { + logx.Info(ctx, "room_lease_released", + slog.String("node_id", s.nodeID), + slog.String("app_code", roomRef.AppCode), + slog.String("room_id", roomRef.RoomID), + ) + } + } +} diff --git a/services/room-service/internal/room/service/service_test.go b/services/room-service/internal/room/service/service_test.go deleted file mode 100644 index 64d04346..00000000 --- a/services/room-service/internal/room/service/service_test.go +++ /dev/null @@ -1,2509 +0,0 @@ -// Package service_test 验证 room-service 的 Room Cell 命令链路、恢复和 outbox 补偿。 -package service_test - -import ( - "context" - "errors" - "fmt" - "slices" - "strings" - "testing" - "time" - - "google.golang.org/protobuf/proto" - roomeventsv1 "hyapp.local/api/proto/events/room/v1" - roomv1 "hyapp.local/api/proto/room/v1" - walletv1 "hyapp.local/api/proto/wallet/v1" - "hyapp/pkg/appcode" - "hyapp/pkg/tencentim" - "hyapp/pkg/xerr" - "hyapp/services/room-service/internal/room/outbox" - roomservice "hyapp/services/room-service/internal/room/service" - "hyapp/services/room-service/internal/router" - "hyapp/services/room-service/internal/testutil/mysqltest" -) - -// fakeWallet 模拟 wallet-service 扣费成功路径。 -type fakeWallet struct{} - -// DebitGift 返回可预测账单回执和钱包结算值,测试只关注 room-service 状态变更。 -func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { - heatValue := int64(req.GetGiftCount()) * 10 - return &walletv1.DebitGiftResponse{ - BillingReceiptId: "receipt_" + req.GetCommandId(), - TransactionId: "wtx_" + req.GetCommandId(), - CoinSpent: heatValue, - GiftPointAdded: heatValue, - HeatValue: heatValue, - PriceVersion: "test-v1", - BalanceAfter: 1000 - heatValue, - }, nil -} - -type takeoverWallet struct { - roomID string - directory *router.MemoryDirectory - takeoverSvc *roomservice.Service -} - -func (w takeoverWallet) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { - w.directory.ForceExpire(roomRouteKeyForTest(w.roomID)) - if _, err := w.takeoverSvc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(w.roomID, 3)}); err != nil { - return nil, err - } - return fakeWallet{}.DebitGift(ctx, req) -} - -func roomRouteKeyForTest(roomID string) string { - return appcode.Default + "\x00" + roomID -} - -// fakeSyncPublisher 记录 room-service 同步推给腾讯云 IM 的房间事件。 -type fakeSyncPublisher struct { - // fail 为 true 时模拟腾讯云 IM PublishRoomEvent 失败。 - fail bool - // events 保存已尝试同步发布的事件。 - events []tencentim.RoomEvent -} - -// EnsureRoomGroup 模拟腾讯云 IM 群组存在。 -func (p *fakeSyncPublisher) EnsureRoomGroup(context.Context, string, int64) error { - return nil -} - -// PublishRoomEvent 记录事件,并按 fail 开关返回错误。 -func (p *fakeSyncPublisher) PublishRoomEvent(_ context.Context, event tencentim.RoomEvent) error { - p.events = append(p.events, event) - if p.fail { - return context.DeadlineExceeded - } - - return nil -} - -// fakeOutboxPublisher 记录 outbox worker 补偿投递的事件信封。 -type fakeOutboxPublisher struct { - // envelopes 保存补偿发布过的 protobuf 信封。 - envelopes []*roomeventsv1.EventEnvelope - // failCount 控制前 N 次投递失败,用于验证 retry_count 和后续成功。 - failCount int - // err 是失败时返回的错误;为空时使用 context.DeadlineExceeded。 - err error - // attempts 记录投递尝试次数,失败也会累计。 - attempts int -} - -// PublishOutboxEvent 记录补偿事件,模拟外部消费者投递成功。 -func (p *fakeOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error { - p.attempts++ - if p.failCount > 0 { - p.failCount-- - if p.err != nil { - return p.err - } - return context.DeadlineExceeded - } - p.envelopes = append(p.envelopes, envelope) - return nil -} - -// mutableClock 让 presence 刷新和 stale 清理测试不依赖真实时间。 -type mutableClock struct { - now time.Time -} - -func (c *mutableClock) Now() time.Time { - return c.now -} - -// newRoomService 组装带真实 MySQL repository 的 room-service,便于测试 Room Cell 持久化语义。 -func newRoomService(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher) *roomservice.Service { - return roomservice.New(roomservice.Config{ - NodeID: nodeID, - LeaseTTL: 10 * time.Second, - RankLimit: 20, - SnapshotEveryN: snapshotEveryN, - }, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher) -} - -// newRoomServiceWithClock 允许测试直接控制房间命令时间和 stale presence 阈值。 -func newRoomServiceWithClock(nodeID string, snapshotEveryN int64, directory router.Directory, repository roomservice.Repository, syncPublisher *fakeSyncPublisher, outboxPublisher *fakeOutboxPublisher, usedClock *mutableClock, staleAfter time.Duration) *roomservice.Service { - return roomservice.New(roomservice.Config{ - NodeID: nodeID, - LeaseTTL: 10 * time.Second, - RankLimit: 20, - SnapshotEveryN: snapshotEveryN, - PresenceStaleAfter: staleAfter, - Clock: usedClock, - }, directory, repository, fakeWallet{}, syncPublisher, outboxPublisher) -} - -// TestCreateRoomDefaultsOwnerAndHostFromActor 验证 CreateRoom 身份语义收敛在 room-service。 -func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - roomID := "room-owner-default" - createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }) - if err != nil { - t.Fatalf("CreateRoom with owner/host defaults failed: %v", err) - } - - if createResp.GetRoom().GetOwnerUserId() != 42 || createResp.GetRoom().GetHostUserId() != 42 { - t.Fatalf("owner/host should default from actor, got owner=%d host=%d", createResp.GetRoom().GetOwnerUserId(), createResp.GetRoom().GetHostUserId()) - } - if user := findRoomUser(createResp.GetRoom(), 42); user == nil || user.GetRole() != "owner" { - t.Fatalf("actor should enter room as owner presence, got %+v", createResp.GetRoom().GetOnlineUsers()) - } - - meta, exists, err := repository.GetRoomMeta(ctx, roomID) - if err != nil { - t.Fatalf("GetRoomMeta failed: %v", err) - } - if !exists || meta.OwnerUserID != 42 || meta.HostUserID != 42 { - t.Fatalf("room meta owner/host mismatch: exists=%v meta=%+v", exists, meta) - } - if ext := createResp.GetRoom().GetRoomExt(); ext["title"] != "Test Room" || ext["cover_url"] != "hyapp://room/default-avatar" || ext["description"] != "" { - t.Fatalf("room profile should be stored in RoomExt: %+v", ext) - } -} - -func TestRoomRouteKeyAlwaysIncludesDefaultAppCode(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-route-default-app" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Route Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - if _, exists, err := directory.Lookup(ctx, roomID); err != nil || exists { - t.Fatalf("raw room_id route key must not exist: exists=%v err=%v", exists, err) - } - lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID)) - if err != nil || !exists { - t.Fatalf("app-scoped route key should exist: exists=%v err=%v", exists, err) - } - if lease.NodeID != "node-a" { - t.Fatalf("route owner mismatch: %+v", lease) - } -} - -func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta("room-owner-single-a", 42), - SeatCount: 10, - Mode: "social", - RoomName: "Owner Room A", - }); err != nil { - t.Fatalf("first CreateRoom failed: %v", err) - } - - _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta("room-owner-single-b", 42), - SeatCount: 10, - Mode: "social", - RoomName: "Owner Room B", - }) - if !xerr.IsCode(err, xerr.Conflict) { - t.Fatalf("second CreateRoom by same owner should conflict, got %v", err) - } - - if meta, exists, err := repository.GetRoomMetaByOwner(ctx, 42); err != nil || !exists || meta.RoomID != "room-owner-single-a" { - t.Fatalf("owner lookup should keep first room: exists=%v meta=%+v err=%v", exists, meta, err) - } -} - -func TestRoomMetaOwnerUniqueIndexMapsConflict(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - - first := roomservice.RoomMeta{ - RoomID: "room-owner-unique-a", - OwnerUserID: 77, - HostUserID: 77, - SeatCount: 10, - Mode: "social", - Status: "active", - } - if err := repository.SaveRoomMeta(ctx, first); err != nil { - t.Fatalf("save first room meta failed: %v", err) - } - - second := first - second.RoomID = "room-owner-unique-b" - if err := repository.SaveRoomMeta(ctx, second); !xerr.IsCode(err, xerr.Conflict) { - t.Fatalf("duplicate owner should map to conflict, got %v", err) - } -} - -func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - - roomID := "room-list-region-a" - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Region A Room", - RoomAvatar: "https://cdn.example.com/room-a.png", - VisibleRegionId: 1001, - }); err != nil { - t.Fatalf("CreateRoom region 1001 failed: %v", err) - } - - usedClock.now = usedClock.now.Add(time.Second) - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta("room-list-region-b", 3), - SeatCount: 10, - Mode: "social", - RoomName: "Region B Room", - VisibleRegionId: 2002, - }); err != nil { - t.Fatalf("CreateRoom region 2002 failed: %v", err) - } - - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp failed: %v", err) - } - if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - GiftId: "rose", - GiftCount: 1, - }); err != nil { - t.Fatalf("SendGift failed: %v", err) - } - - listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - ViewerUserId: 99, - VisibleRegionId: 1001, - Tab: "hot", - Limit: 20, - }) - if err != nil { - t.Fatalf("ListRooms region 1001 failed: %v", err) - } - if len(listResp.GetRooms()) != 1 { - t.Fatalf("region list should only expose matching rooms: %+v", listResp.GetRooms()) - } - item := listResp.GetRooms()[0] - if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 { - t.Fatalf("room list item identity mismatch: %+v", item) - } - if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 10 { - t.Fatalf("room list projection did not track Room Cell state: %+v", item) - } - if item.GetTitle() != "Region A Room" || item.GetCoverUrl() != "https://cdn.example.com/room-a.png" { - t.Fatalf("room list should project RoomExt title/cover_url: %+v", item) - } - - otherRegionResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - ViewerUserId: 99, - VisibleRegionId: 3003, - Tab: "hot", - Limit: 20, - }) - if err != nil { - t.Fatalf("ListRooms other region failed: %v", err) - } - if len(otherRegionResp.GetRooms()) != 0 { - t.Fatalf("region isolation should not leak rooms: %+v", otherRegionResp.GetRooms()) - } -} - -func TestGetMyRoomDoesNotDependOnRoomListProjection(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-my-room-owner" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: 10, - Mode: "voice", - RoomName: "Owner Top Card", - RoomAvatar: "https://cdn.example.com/owner-room.png", - VisibleRegionId: 1001, - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - repository.DeleteRoomListEntry(roomID) - - resp, err := svc.GetMyRoom(ctx, &roomv1.GetMyRoomRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - OwnerUserId: 42, - }) - if err != nil { - t.Fatalf("GetMyRoom failed: %v", err) - } - if !resp.GetHasRoom() || resp.GetRoom().GetRoomId() != roomID { - t.Fatalf("my room should return owner room from rooms metadata: %+v", resp) - } - if resp.GetRoom().GetTitle() != "Owner Top Card" || resp.GetRoom().GetSeatCount() != 10 || resp.GetRoom().GetVisibleRegionId() != 1001 { - t.Fatalf("my room card should use snapshot/meta fields: %+v", resp.GetRoom()) - } -} - -func TestListRoomFeedsVisitedUsesJoinRoomFeed(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-visited-feed" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "voice", - RoomName: "Visited Room", - VisibleRegionId: 1001, - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - resp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 2, - VisibleRegionId: 1001, - Tab: "visited", - Limit: 20, - }) - if err != nil { - t.Fatalf("ListRoomFeeds visited failed: %v", err) - } - if len(resp.GetRooms()) != 1 || resp.GetRooms()[0].GetRoomId() != roomID { - t.Fatalf("visited feed should expose joined room: %+v", resp.GetRooms()) - } - - otherUserResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 3, - VisibleRegionId: 1001, - Tab: "visited", - Limit: 20, - }) - if err != nil { - t.Fatalf("ListRoomFeeds visited for other user failed: %v", err) - } - if len(otherUserResp.GetRooms()) != 0 { - t.Fatalf("visited feed must be scoped to viewer: %+v", otherUserResp.GetRooms()) - } -} - -func TestListRoomFeedsFriendFollowingUsesGatewayRelations(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - for _, room := range []struct { - roomID string - ownerID int64 - regionID int64 - title string - }{ - {roomID: "room-related-low", ownerID: 10002, regionID: 1001, title: "Low Relation"}, - {roomID: "room-related-high", ownerID: 10003, regionID: 1001, title: "High Relation"}, - {roomID: "room-related-other-region", ownerID: 10004, regionID: 2002, title: "Other Region"}, - } { - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(room.roomID, room.ownerID), - SeatCount: 10, - Mode: "voice", - RoomName: room.title, - VisibleRegionId: room.regionID, - }); err != nil { - t.Fatalf("CreateRoom %s failed: %v", room.roomID, err) - } - } - - resp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 42, - VisibleRegionId: 1001, - Tab: "following", - Limit: 1, - RelatedUsers: []*roomv1.RoomFeedRelatedUser{ - {UserId: 10002, RelationUpdatedAtMs: 8000}, - {UserId: 10003, RelationUpdatedAtMs: 9000}, - {UserId: 10004, RelationUpdatedAtMs: 10000}, - }, - }) - if err != nil { - t.Fatalf("ListRoomFeeds following failed: %v", err) - } - if len(resp.GetRooms()) != 1 || resp.GetRooms()[0].GetRoomId() != "room-related-high" { - t.Fatalf("following feed should sort by user-service relation time and filter region: %+v", resp.GetRooms()) - } - if resp.GetNextCursor() == "" { - t.Fatal("following feed should return cursor when more related rooms exist") - } - - nextResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 42, - VisibleRegionId: 1001, - Tab: "following", - Limit: 20, - Cursor: resp.GetNextCursor(), - RelatedUsers: []*roomv1.RoomFeedRelatedUser{ - {UserId: 10002, RelationUpdatedAtMs: 8000}, - {UserId: 10003, RelationUpdatedAtMs: 9000}, - {UserId: 10004, RelationUpdatedAtMs: 10000}, - }, - }) - if err != nil { - t.Fatalf("ListRoomFeeds following next page failed: %v", err) - } - if len(nextResp.GetRooms()) != 1 || nextResp.GetRooms()[0].GetRoomId() != "room-related-low" { - t.Fatalf("following cursor should continue after previous relation subject: %+v", nextResp.GetRooms()) - } - - emptyResp, err := svc.ListRoomFeeds(ctx, &roomv1.ListRoomFeedsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 42, - VisibleRegionId: 1001, - Tab: "friend", - Limit: 20, - }) - if err != nil { - t.Fatalf("ListRoomFeeds friend with empty related users failed: %v", err) - } - if len(emptyResp.GetRooms()) != 0 { - t.Fatalf("friend feed without user-service relations must be empty: %+v", emptyResp.GetRooms()) - } -} - -func TestListRoomsRejectsUserFeedTabs(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - for _, tab := range []string{"visited", "friend", "following", "me"} { - _, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 2, - VisibleRegionId: 1001, - Tab: tab, - Limit: 20, - }) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("ListRooms must reject %q tab: %v", tab, err) - } - } -} - -func TestGetCurrentRoomReturnsRecoverablePresence(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-current-presence" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "voice", - RoomName: "Current Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - UserId: 2, - }) - if err != nil { - t.Fatalf("GetCurrentRoom failed: %v", err) - } - if !current.GetHasCurrentRoom() || current.GetRoomId() != roomID || current.GetRole() != "audience" { - t.Fatalf("current room response mismatch: %+v", current) - } - if current.GetPublishState() != "pending_publish" || current.GetMicSessionId() != micResp.GetMicSessionId() || !current.GetNeedRtcToken() || !current.GetNeedJoinImGroup() { - t.Fatalf("current mic restore fields mismatch: %+v", current) - } -} - -func TestGetCurrentRoomReturnsOwnerAfterCreate(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-current-owner-after-create" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "voice", - RoomName: "Current Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - UserId: 1, - }) - if err != nil { - t.Fatalf("GetCurrentRoom failed: %v", err) - } - if !current.GetHasCurrentRoom() || current.GetRoomId() != roomID || current.GetRole() != "owner" || !current.GetNeedJoinImGroup() { - t.Fatalf("owner should recover room immediately after CreateRoom: %+v", current) - } - if current.GetNeedRtcToken() || current.GetPublishState() != "" || current.GetMicSessionId() != "" { - t.Fatalf("owner not on mic should not require RTC restore: %+v", current) - } -} - -func TestGetCurrentRoomReturnsEmptyAfterLeave(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-current-left" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "voice", - RoomName: "Current Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("LeaveRoom failed: %v", err) - } - - current, err := svc.GetCurrentRoom(ctx, &roomv1.GetCurrentRoomRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - UserId: 2, - }) - if err != nil { - t.Fatalf("GetCurrentRoom failed: %v", err) - } - if current.GetHasCurrentRoom() || current.GetRoomId() != "" { - t.Fatalf("left user should not have current room: %+v", current) - } -} - -func TestGetRoomSnapshotRequiresViewerPresence(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-snapshot-read" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "voice", - RoomName: "Snapshot Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - RoomId: roomID, - ViewerUserId: 2, - }); !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("non-presence viewer should not read full snapshot, got %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - resp, err := svc.GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - RoomId: roomID, - ViewerUserId: 2, - }) - if err != nil { - t.Fatalf("GetRoomSnapshot failed: %v", err) - } - if resp.GetRoom().GetRoomId() != roomID || resp.GetRoom().GetVersion() == 0 || findRoomUser(resp.GetRoom(), 2) == nil { - t.Fatalf("snapshot response mismatch: %+v", resp.GetRoom()) - } -} - -func TestCreateRoomRejectsInvalidRoomID(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta("room:1001", 42), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("CreateRoom should reject invalid room_id, got %v", err) - } - if _, exists, getErr := repository.GetRoomMeta(ctx, "room:1001"); getErr != nil || exists { - t.Fatalf("invalid room_id must not create room meta: exists=%v err=%v", exists, getErr) - } -} - -func TestCreateRoomRejectsMissingRequiredFields(t *testing.T) { - tests := []struct { - name string - req *roomv1.CreateRoomRequest - }{ - {name: "command_id", req: &roomv1.CreateRoomRequest{Meta: &roomv1.RequestMeta{RoomId: "room-missing-command", ActorUserId: 42}, SeatCount: 10, Mode: "social", RoomName: "Test Room"}}, - {name: "mode", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-mode", 42), SeatCount: 10, RoomName: "Test Room"}}, - {name: "room_name", req: &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-missing-name", 42), SeatCount: 10, Mode: "social"}}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), mysqltest.NewRepository(t), &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - _, err := svc.CreateRoom(context.Background(), test.req) - - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("CreateRoom should reject missing %s, got %v", test.name, err) - } - }) - } -} - -func TestCreateRoomUsesDefaultSeatConfig(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - resp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta("room-default-seat-config", 42), - Mode: "social", - RoomName: "Default Seat Room", - }) - if err != nil { - t.Fatalf("CreateRoom without seat_count should use default config: %v", err) - } - if got := len(resp.GetRoom().GetMicSeats()); got != 15 { - t.Fatalf("default room seat count mismatch: got %d", got) - } -} - -func TestCreateRoomRejectsDisallowedSeatCount(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - - for _, seatCount := range []int32{8, 12, 31, -1} { - _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(fmt.Sprintf("room-disallowed-seat-%d", seatCount), 42), - SeatCount: seatCount, - Mode: "social", - RoomName: "Bad Seat Room", - }) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("CreateRoom should reject seat_count=%d, got %v", seatCount, err) - } - } -} - -func TestUpdateRoomProfileUpdatesProjectionAndExpandsSeats(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - syncPublisher := &fakeSyncPublisher{} - outboxPublisher := &fakeOutboxPublisher{} - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, outboxPublisher) - roomID := "room-profile-update" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: 10, - Mode: "voice", - RoomName: "Old Room", - RoomAvatar: "https://cdn.example.com/old.png", - RoomDescription: "old desc", - VisibleRegionId: 1001, - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - resp, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ - Meta: roomservice.NewRequestMeta(roomID, 42), - RoomName: new(" New Room "), - RoomAvatar: new(" https://cdn.example.com/new.png "), - RoomDescription: new(" new desc "), - SeatCount: int32Ptr(20), - }) - if err != nil { - t.Fatalf("UpdateRoomProfile failed: %v", err) - } - - snapshot := resp.GetRoom() - if !resp.GetResult().GetApplied() || snapshot.GetVersion() != 2 { - t.Fatalf("profile update should advance room version: %+v", resp.GetResult()) - } - if got := len(snapshot.GetMicSeats()); got != 20 { - t.Fatalf("expanded seat count mismatch: got %d", got) - } - ext := snapshot.GetRoomExt() - if ext["title"] != "New Room" || ext["cover_url"] != "https://cdn.example.com/new.png" || ext["description"] != "new desc" { - t.Fatalf("room ext mismatch after profile update: %+v", ext) - } - meta, exists, err := repository.GetRoomMeta(ctx, roomID) - if err != nil || !exists || meta.SeatCount != 20 { - t.Fatalf("room meta seat_count should update: exists=%v meta=%+v err=%v", exists, meta, err) - } - listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - ViewerUserId: 99, - VisibleRegionId: 1001, - Tab: "hot", - Limit: 20, - }) - if err != nil { - t.Fatalf("ListRooms failed: %v", err) - } - if len(listResp.GetRooms()) != 1 || listResp.GetRooms()[0].GetTitle() != "New Room" || listResp.GetRooms()[0].GetSeatCount() != 20 { - t.Fatalf("room list projection should reflect profile update: %+v", listResp.GetRooms()) - } - if len(outboxPublisher.envelopes) != 1 || outboxPublisher.envelopes[0].GetEventType() != "RoomProfileUpdated" { - t.Fatalf("profile update should publish one RoomProfileUpdated outbox envelope: %+v", outboxPublisher.envelopes) - } - if len(syncPublisher.events) != 1 || syncPublisher.events[0].EventType != "room_profile_updated" || syncPublisher.events[0].Attributes["seat_count"] != "20" { - t.Fatalf("profile update should publish room_profile_updated: %+v", syncPublisher.events) - } -} - -func TestUpdateRoomProfileRejectsInvalidSeatCount(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-profile-invalid-seat" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: 10, - Mode: "voice", - RoomName: "Seat Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - _, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ - Meta: roomservice.NewRequestMeta(roomID, 42), - SeatCount: int32Ptr(12), - }) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("UpdateRoomProfile should reject disallowed seat count, got %v", err) - } -} - -func TestUpdateRoomProfileRejectsShrinkWhenRemovedSeatsBusy(t *testing.T) { - t.Run("occupied", func(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-shrink-occupied" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 20}); err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - _, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: int32Ptr(10), - }) - if !xerr.IsCode(err, xerr.Conflict) { - t.Fatalf("shrinking occupied high seat should conflict, got %v", err) - } - }) - - t.Run("locked", func(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-shrink-locked" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 20, Mode: "voice", RoomName: "Shrink Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 20, Locked: true}); err != nil { - t.Fatalf("SetMicSeatLock failed: %v", err) - } - - _, err := svc.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: int32Ptr(10), - }) - if !xerr.IsCode(err, xerr.Conflict) { - t.Fatalf("shrinking locked high seat should conflict, got %v", err) - } - }) -} - -func TestUpdateRoomProfileRecoveryReplay(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-profile-replay" - - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Old Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.UpdateRoomProfile(ctx, &roomv1.UpdateRoomProfileRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - RoomName: new("Replay Room"), - RoomDescription: new("replayed desc"), - SeatCount: int32Ptr(20), - }); err != nil { - t.Fatalf("UpdateRoomProfile failed: %v", err) - } - - directory.ForceExpire(roomRouteKeyForTest(roomID)) - serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}) - if err != nil { - t.Fatalf("JoinRoom on recovered service failed: %v", err) - } - snapshot := joinResp.GetRoom() - if len(snapshot.GetMicSeats()) != 20 || snapshot.GetRoomExt()["title"] != "Replay Room" || snapshot.GetRoomExt()["description"] != "replayed desc" { - t.Fatalf("recovered profile state mismatch: seats=%d ext=%+v", len(snapshot.GetMicSeats()), snapshot.GetRoomExt()) - } -} - -// TestRoomLifecycleGiftAndGuards 覆盖创建、进房、上麦、送礼、禁言和踢人的主链路。 -func TestRoomLifecycleGiftAndGuards(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - syncPublisher := &fakeSyncPublisher{} - outboxPublisher := &fakeOutboxPublisher{} - svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher) - - roomID := "room-1001" - - // 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。 - createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }) - if err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - if !createResp.GetResult().GetApplied() { - t.Fatalf("CreateRoom should apply") - } - - // JoinRoom 只进入 room-service 业务 presence,不涉及腾讯云 IM 连接态。 - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - Role: "audience", - }); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - // MicUp 验证 Room Cell 内麦位占用和版本变更。 - micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - SeatNo: 1, - }) - if err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - if micResp.GetSeatNo() != 1 { - t.Fatalf("MicUp seat mismatch: got %d", micResp.GetSeatNo()) - } - - // SendGift 先走 fakeWallet,再更新热度和房间内本地礼物榜。 - giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - GiftId: "rose", - GiftCount: 2, - }) - if err != nil { - t.Fatalf("SendGift failed: %v", err) - } - - if got := giftResp.GetRoomHeat(); got != 20 { - t.Fatalf("room heat mismatch: got %d want 20", got) - } - - if len(giftResp.GetGiftRank()) != 1 || giftResp.GetGiftRank()[0].GetUserId() != 1 { - t.Fatalf("gift rank mismatch: %+v", giftResp.GetGiftRank()) - } - - if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_gift_sent" { - t.Fatalf("expected room_gift_sent sync publish, got %+v", syncPublisher.events) - } - - // MuteUser 后 CheckSpeakPermission 必须拒绝外部 IM 的公屏发送请求。 - if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - Muted: true, - }); err != nil { - t.Fatalf("MuteUser failed: %v", err) - } - - speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{ - RoomId: roomID, - UserId: 2, - }) - if err != nil { - t.Fatalf("CheckSpeakPermission failed: %v", err) - } - - if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" { - t.Fatalf("unexpected speak guard response: %+v", speakResp) - } - - // KickUser 后 VerifyRoomPresence 必须拒绝外部 IM 的进群请求。 - if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - }); err != nil { - t.Fatalf("KickUser failed: %v", err) - } - - presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{ - RoomId: roomID, - UserId: 2, - }) - if err != nil { - t.Fatalf("VerifyRoomPresence failed: %v", err) - } - - if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" { - t.Fatalf("unexpected presence guard response: %+v", presenceResp) - } - if got := countPendingMicDownReason(t, repository, "kick"); got != 1 { - t.Fatalf("KickUser should write one RoomMicChanged/down reason=kick, got %d", got) - } -} - -func TestRoomManagementPermissionsAndHostFlow(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-management-flow" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { - t.Fatalf("JoinRoom user3 failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp user2 failed: %v", err) - } - - _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}) - if !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("audience should not mic down others, got %v", err) - } - - adminResp, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 3, - Enabled: true, - }) - if err != nil { - t.Fatalf("SetRoomAdmin failed: %v", err) - } - if !containsInt64(adminResp.GetRoom().GetAdminUserIds(), 3) { - t.Fatalf("admin ids should contain user3: %+v", adminResp.GetRoom().GetAdminUserIds()) - } - - if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}); err != nil { - t.Fatalf("admin MicDown failed: %v", err) - } - hostResp, err := svc.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - }) - if err != nil { - t.Fatalf("TransferRoomHost failed: %v", err) - } - if hostResp.GetRoom().GetHostUserId() != 2 || !containsInt64(hostResp.GetRoom().GetAdminUserIds(), 2) { - t.Fatalf("host transfer should update host and admin set: %+v", hostResp.GetRoom()) - } - - _, err = svc.MuteUser(ctx, &roomv1.MuteUserRequest{ - Meta: roomservice.NewRequestMeta(roomID, 3), - TargetUserId: 2, - Muted: true, - }) - if !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("admin should not mute current host, got %v", err) - } - if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 2), Enabled: false}); err != nil { - t.Fatalf("host SetChatEnabled failed: %v", err) - } - speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 1}) - if err != nil { - t.Fatalf("CheckSpeakPermission failed: %v", err) - } - if speakResp.GetAllowed() || speakResp.GetReason() != "chat_disabled" { - t.Fatalf("chat disabled should block all users: %+v", speakResp) - } -} - -func TestMicPublishingConfirmAndStaleEvents(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-mic-publish-confirm" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("MicUp failed: %v", err) - } - seat := findSeat(micResp.GetRoom(), 1) - if seat == nil || seat.GetUserId() != 2 || seat.GetPublishState() != "pending_publish" || seat.GetMicSessionId() == "" { - t.Fatalf("MicUp should create pending_publish session: seat=%+v", seat) - } - if micResp.GetMicSessionId() != seat.GetMicSessionId() || micResp.GetPublishDeadlineMs() != seat.GetPublishDeadlineMs() { - t.Fatalf("MicUp response should expose current mic session: resp=%+v seat=%+v", micResp, seat) - } - if seat.GetLastPublishEventTimeMs() != 0 { - t.Fatalf("new pending session should not pre-fill publish event time: seat=%+v", seat) - } - - confirmEventTime := usedClock.now.Add(-time.Second).UnixMilli() - confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - MicSessionId: micResp.GetMicSessionId(), - RoomVersion: micResp.GetResult().GetRoomVersion(), - EventTimeMs: confirmEventTime, - Source: "client", - }) - if err != nil { - t.Fatalf("ConfirmMicPublishing failed: %v", err) - } - seat = findSeat(confirmResp.GetRoom(), 1) - if seat == nil || seat.GetPublishState() != "publishing" || seat.GetLastPublishEventTimeMs() != confirmEventTime { - t.Fatalf("confirm should move seat to publishing: seat=%+v", seat) - } - - staleResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - MicSessionId: micResp.GetMicSessionId(), - RoomVersion: micResp.GetResult().GetRoomVersion(), - EventTimeMs: confirmEventTime, - Source: "rtc_webhook", - }) - if err != nil { - t.Fatalf("stale ConfirmMicPublishing should be ignored without error: %v", err) - } - if staleResp.GetResult().GetApplied() { - t.Fatalf("stale confirmation must not advance room version") - } -} - -func TestAudienceCanChangeOwnMicSeat(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-self-change-mic" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp user2 failed: %v", err) - } - - changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - TargetUserId: 2, - SeatNo: 3, - }) - if err != nil { - t.Fatalf("audience should be able to change own mic seat: %v", err) - } - if from := findSeat(changeResp.GetRoom(), 1); from == nil || from.GetUserId() != 0 { - t.Fatalf("source seat should be empty after switch: %+v", from) - } - if to := findSeat(changeResp.GetRoom(), 3); to == nil || to.GetUserId() != 2 { - t.Fatalf("target seat should contain user2 after switch: %+v", to) - } -} - -func TestApplyRTCEventConfirmsAudioAndClearsMicOnStopOrExit(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - syncPublisher := &fakeSyncPublisher{} - svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-rtc-event" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("MicUp user2 failed: %v", err) - } - - startEventTime := usedClock.now.Add(time.Second).UnixMilli() - startResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - TargetUserId: 2, - EventType: "audio_started", - EventTimeMs: startEventTime, - Source: "tencent_rtc_callback", - ExternalEventId: "rtc_evt_start", - }) - if err != nil { - t.Fatalf("ApplyRTCEvent audio_started failed: %v", err) - } - seat := findSeat(startResp.GetRoom(), 1) - if seat == nil || seat.GetPublishState() != "publishing" || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetLastPublishEventTimeMs() != startEventTime { - t.Fatalf("RTC audio_started should confirm current mic session: seat=%+v", seat) - } - - stopResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - TargetUserId: 2, - EventType: "audio_stopped", - EventTimeMs: usedClock.now.Add(2 * time.Second).UnixMilli(), - Reason: "rtc_audio_stopped:0", - Source: "tencent_rtc_callback", - }) - if err != nil { - t.Fatalf("ApplyRTCEvent audio_stopped failed: %v", err) - } - if seat := findSeat(stopResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { - t.Fatalf("RTC audio_stopped should release mic seat: seat=%+v", seat) - } - - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { - t.Fatalf("JoinRoom user3 failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 3), SeatNo: 2}); err != nil { - t.Fatalf("MicUp user3 failed: %v", err) - } - exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ - Meta: roomservice.NewRequestMeta(roomID, 3), - TargetUserId: 3, - EventType: "room_exited", - EventTimeMs: usedClock.now.Add(3 * time.Second).UnixMilli(), - Reason: "rtc_room_exited:2", - Source: "tencent_rtc_callback", - }) - if err != nil { - t.Fatalf("ApplyRTCEvent room_exited failed: %v", err) - } - if user := findRoomUser(exitResp.GetRoom(), 3); user == nil { - t.Fatalf("RTC room_exited must not remove business presence") - } - if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 0 { - t.Fatalf("RTC room_exited should release occupied mic: seat=%+v", seat) - } - if len(syncPublisher.events) == 0 || syncPublisher.events[len(syncPublisher.events)-1].EventType != "room_mic_down" { - t.Fatalf("RTC room_exited should publish room_mic_down: %+v", syncPublisher.events) - } -} - -func TestOldRTCRoomExitedDoesNotClearRejoinedUserOrNewMicSession(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-rtc-exit-replay" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("first MicUp failed: %v", err) - } - oldExitEventTime := usedClock.now.Add(time.Second).UnixMilli() - - usedClock.now = usedClock.now.Add(2 * time.Second) - if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("LeaveRoom failed: %v", err) - } - - usedClock.now = usedClock.now.Add(2 * time.Second) - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("rejoin user2 failed: %v", err) - } - newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2}) - if err != nil { - t.Fatalf("second MicUp failed: %v", err) - } - if newMic.GetMicSessionId() == oldMic.GetMicSessionId() { - t.Fatalf("rejoined MicUp must create new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId()) - } - - exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - TargetUserId: 2, - EventType: "room_exited", - EventTimeMs: oldExitEventTime, - Reason: "rtc_room_exited:old", - Source: "tencent_rtc_callback", - ExternalEventId: "rtc_evt_old_exit", - }) - if err != nil { - t.Fatalf("old room_exited should be ignored without error: %v", err) - } - if exitResp.GetResult().GetApplied() { - t.Fatalf("old room_exited should be a no-op: %+v", exitResp.GetResult()) - } - if user := findRoomUser(exitResp.GetRoom(), 2); user == nil { - t.Fatalf("old room_exited must not remove rejoined user") - } - if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() { - t.Fatalf("old room_exited must not clear new mic session: seat=%+v new=%+v old=%+v", seat, newMic, oldMic) - } -} - -func TestRTCRoomExitedRecoveryKeepsBusinessPresence(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - serviceA := newRoomServiceWithClock("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-rtc-exit-recovery-presence" - - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - if _, err := serviceA.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp user2 failed: %v", err) - } - - usedClock.now = usedClock.now.Add(time.Second) - exitResp, err := serviceA.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - TargetUserId: 2, - EventType: "room_exited", - EventTimeMs: usedClock.now.UnixMilli(), - Reason: "rtc_room_exited:recovery", - Source: "tencent_rtc_callback", - }) - if err != nil { - t.Fatalf("ApplyRTCEvent room_exited failed: %v", err) - } - if user := findRoomUser(exitResp.GetRoom(), 2); user == nil { - t.Fatalf("real-time room_exited must keep business presence") - } - if seat := findSeat(exitResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 { - t.Fatalf("real-time room_exited should release mic seat: seat=%+v", seat) - } - - // snapshotEveryN=100 保证 room_exited 后没有最新快照;node-b 必须通过 command log replay 恢复同一语义。 - directory.ForceExpire(roomRouteKeyForTest(roomID)) - serviceB := newRoomServiceWithClock("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - recoveredResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}) - if err != nil { - t.Fatalf("JoinRoom on recovered service failed: %v", err) - } - if user := findRoomUser(recoveredResp.GetRoom(), 2); user == nil { - t.Fatalf("replayed room_exited must keep business presence") - } - if seat := findSeat(recoveredResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { - t.Fatalf("replayed room_exited should keep released mic seat: seat=%+v", seat) - } -} - -func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - directory := router.NewMemoryDirectory() - syncPublisher := &fakeSyncPublisher{} - svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-mic-publish-timeout" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("MicUp failed: %v", err) - } - usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs()) - // 真实配置里 lease_ttl 可能短于 publish timeout;worker 必须能在无其他 owner 时续租并执行超时下麦。 - directory.ForceExpire(roomRouteKeyForTest(roomID)) - if err := svc.SweepMicPublishTimeouts(ctx); err != nil { - t.Fatalf("SweepMicPublishTimeouts failed: %v", err) - } - - joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}) - if err != nil { - t.Fatalf("JoinRoom refresh failed: %v", err) - } - if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { - t.Fatalf("publish timeout should release seat and session: %+v", seat) - } - if len(syncPublisher.events) == 0 { - t.Fatalf("expected timeout mic_down event") - } - last := syncPublisher.events[len(syncPublisher.events)-1] - if last.EventType != "room_mic_down" || last.Attributes["reason"] != "publish_timeout" || last.Attributes["mic_session_id"] != micResp.GetMicSessionId() { - t.Fatalf("timeout should publish mic_down with session reason: %+v", last) - } -} - -func TestOldMicSessionEventsDoNotClearNewMicUp(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-mic-old-session" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("first MicUp failed: %v", err) - } - if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("MicDown failed: %v", err) - } - - usedClock.now = usedClock.now.Add(time.Second) - newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("second MicUp failed: %v", err) - } - if newMic.GetMicSessionId() == oldMic.GetMicSessionId() { - t.Fatalf("new MicUp must create a new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId()) - } - - usedClock.now = time.UnixMilli(oldMic.GetPublishDeadlineMs()) - if err := svc.SweepMicPublishTimeouts(ctx); err != nil { - t.Fatalf("old timeout sweep failed: %v", err) - } - staleConfirm, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - MicSessionId: oldMic.GetMicSessionId(), - RoomVersion: oldMic.GetResult().GetRoomVersion(), - EventTimeMs: usedClock.now.Add(time.Second).UnixMilli(), - Source: "rtc_webhook", - }) - if err != nil { - t.Fatalf("old session confirm should be ignored without error: %v", err) - } - seat := findSeat(staleConfirm.GetRoom(), 1) - if seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() || seat.GetPublishState() != "pending_publish" { - t.Fatalf("old session event must not clear new MicUp: seat=%+v new=%+v old=%+v", seat, newMic, oldMic) - } -} - -func TestMicSeatLockAndNoopIdempotency(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - syncPublisher := &fakeSyncPublisher{fail: true} - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) - roomID := "room-mic-lock" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - lockResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatNo: 2, - Locked: true, - }) - if err != nil { - t.Fatalf("SetMicSeatLock failed: %v", err) - } - if seat := findSeat(lockResp.GetRoom(), 2); seat == nil || !seat.GetLocked() { - t.Fatalf("seat should be locked: %+v", seat) - } - if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 { - t.Fatalf("lock should write one outbox event, got %d", got) - } - - repeatResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatNo: 2, - Locked: true, - }) - if err != nil { - t.Fatalf("repeat SetMicSeatLock failed: %v", err) - } - if repeatResp.GetResult().GetApplied() { - t.Fatalf("repeat lock should be a no-op") - } - if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 { - t.Fatalf("repeat lock should not write duplicate outbox, got %d", got) - } - - _, err = svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2}) - if !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("locked seat MicUp should be denied, got %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp unlocked seat failed: %v", err) - } - _, err = svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 1, Locked: true}) - if !xerr.IsCode(err, xerr.Conflict) { - t.Fatalf("locking occupied seat should conflict, got %v", err) - } -} - -func TestKickUnbanAndAdminRemoval(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-ban-unban" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { - t.Fatalf("JoinRoom user3 failed: %v", err) - } - if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil { - t.Fatalf("SetRoomAdmin user2 failed: %v", err) - } - if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3, Enabled: true}); err != nil { - t.Fatalf("SetRoomAdmin user3 failed: %v", err) - } - - kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}) - if err != nil { - t.Fatalf("KickUser failed: %v", err) - } - if !containsInt64(kickResp.GetRoom().GetBanUserIds(), 2) || containsInt64(kickResp.GetRoom().GetAdminUserIds(), 2) { - t.Fatalf("kick should ban user2 and remove admin: %+v", kickResp.GetRoom()) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("banned user should not join, got %v", err) - } - presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2}) - if err != nil { - t.Fatalf("VerifyRoomPresence failed: %v", err) - } - if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" { - t.Fatalf("banned user should fail presence guard: %+v", presenceResp) - } - - _, err = svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}) - if !xerr.IsCode(err, xerr.PermissionDenied) { - t.Fatalf("admin should not unban, got %v", err) - } - unbanResp, err := svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}) - if err != nil { - t.Fatalf("owner UnbanUser failed: %v", err) - } - if containsInt64(unbanResp.GetRoom().GetBanUserIds(), 2) || containsInt64(unbanResp.GetRoom().GetAdminUserIds(), 2) { - t.Fatalf("unban should not restore ban or admin state: %+v", unbanResp.GetRoom()) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("unbanned user should join again: %v", err) - } -} - -func TestJoinRoomRejectsClosedRoom(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-closed" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - repository.SetRoomStatus(roomID, "closed") - - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.RoomClosed) { - t.Fatalf("closed room should reject JoinRoom with ROOM_CLOSED, got %v", err) - } - - presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 1}) - if err != nil { - t.Fatalf("VerifyRoomPresence failed: %v", err) - } - if presenceResp.GetPresent() || presenceResp.GetReason() != "room_closed" { - t.Fatalf("closed room should fail presence guard: %+v", presenceResp) - } -} - -func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - syncPublisher := &fakeSyncPublisher{} - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) - roomID := "room-close-command" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Close Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - closeResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Reason: "owner_closed"}) - if err != nil { - t.Fatalf("CloseRoom failed: %v", err) - } - if closeResp.GetRoom().GetStatus() != "closed" || len(closeResp.GetRoom().GetOnlineUsers()) != 0 { - t.Fatalf("closed room should clear presence: %+v", closeResp.GetRoom()) - } - if seat := findSeat(closeResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" { - t.Fatalf("closed room should release seats: %+v", seat) - } - if got := countPendingMicDownReason(t, repository, "room_closed"); got != 1 { - t.Fatalf("CloseRoom should write one RoomMicChanged/down reason=room_closed, got %d", got) - } - meta, exists, err := repository.GetRoomMeta(ctx, roomID) - if err != nil || !exists || meta.Status != "closed" { - t.Fatalf("room meta should be closed: exists=%v meta=%+v err=%v", exists, meta, err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.RoomClosed) { - t.Fatalf("closed room should reject JoinRoom, got %v", err) - } - if !hasSyncEventType(syncPublisher.events, "room_closed") { - t.Fatalf("CloseRoom should publish room_closed event: %+v", syncPublisher.events) - } -} - -func TestDrainingRejectsNewRoomCommands(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-draining" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Drain Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - svc.MarkDraining() - - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.Unavailable) { - t.Fatalf("draining service should reject new JoinRoom, got %v", err) - } - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 10, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) { - t.Fatalf("draining service should reject new CreateRoom, got %v", err) - } -} - -func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-command-conflict" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - firstMeta := roomservice.NewRequestMeta(roomID, 1) - firstMeta.CommandId = "cmd-chat-disable-fixed" - firstMeta.RequestId = "req-first" - if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: firstMeta, Enabled: false}); err != nil { - t.Fatalf("first SetChatEnabled failed: %v", err) - } - - retryMeta := roomservice.NewRequestMeta(roomID, 1) - retryMeta.CommandId = firstMeta.GetCommandId() - retryMeta.RequestId = "req-retry" - retryMeta.SessionId = "different-session" - retryMeta.SentAtMs = firstMeta.GetSentAtMs() + 1000 - retryResp, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: false}) - if err != nil { - t.Fatalf("same command with different trace fields should be idempotent: %v", err) - } - if retryResp.GetResult().GetApplied() { - t.Fatalf("idempotent retry should not apply again") - } - - _, err = svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: true}) - if !xerr.IsCode(err, xerr.Conflict) { - t.Fatalf("same command_id with different business payload should conflict, got %v", err) - } -} - -func TestRoomManagementRecoveryReplay(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-management-replay" - - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { - t.Fatalf("JoinRoom user3 failed: %v", err) - } - if _, err := serviceA.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 4, Locked: true}); err != nil { - t.Fatalf("SetMicSeatLock failed: %v", err) - } - if _, err := serviceA.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Enabled: false}); err != nil { - t.Fatalf("SetChatEnabled failed: %v", err) - } - if _, err := serviceA.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil { - t.Fatalf("SetRoomAdmin failed: %v", err) - } - if _, err := serviceA.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}); err != nil { - t.Fatalf("TransferRoomHost failed: %v", err) - } - if _, err := serviceA.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil { - t.Fatalf("KickUser failed: %v", err) - } - if _, err := serviceA.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil { - t.Fatalf("UnbanUser failed: %v", err) - } - - directory.ForceExpire(roomRouteKeyForTest(roomID)) - serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) - if err != nil { - t.Fatalf("JoinRoom on recovered service failed: %v", err) - } - snapshot := joinResp.GetRoom() - if snapshot.GetHostUserId() != 2 || !containsInt64(snapshot.GetAdminUserIds(), 2) { - t.Fatalf("host/admin state did not replay: %+v", snapshot) - } - if snapshot.GetChatEnabled() { - t.Fatalf("chat_enabled should replay as false") - } - if seat := findSeat(snapshot, 4); seat == nil || !seat.GetLocked() { - t.Fatalf("locked seat did not replay: %+v", seat) - } - if containsInt64(snapshot.GetBanUserIds(), 3) { - t.Fatalf("unban should replay after kick: %+v", snapshot.GetBanUserIds()) - } -} - -// TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent 验证重连只刷新 presence,不重复广播进房。 -func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - svc := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-repeat-join" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - Role: "audience", - }); err != nil { - t.Fatalf("first JoinRoom failed: %v", err) - } - beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined") - - usedClock.now = usedClock.now.Add(5 * time.Second) - joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - Role: "audience", - }) - if err != nil { - t.Fatalf("repeat JoinRoom failed: %v", err) - } - - user := findRoomUser(joinResp.GetRoom(), 2) - if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() { - t.Fatalf("last_seen was not refreshed: user=%+v now=%d", user, usedClock.now.UnixMilli()) - } - if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents { - t.Fatalf("repeat JoinRoom should not create duplicate join outbox: before=%d after=%d", beforeJoinEvents, after) - } -} - -func TestRoomHeartbeatRefreshesExistingPresenceOnly(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-heartbeat" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "voice", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - beforeJoinEvents := countPendingOutboxType(t, repository, "RoomUserJoined") - - usedClock.now = usedClock.now.Add(30 * time.Second) - heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}) - if err != nil { - t.Fatalf("RoomHeartbeat failed: %v", err) - } - user := findRoomUser(heartbeatResp.GetRoom(), 2) - if user == nil || user.GetLastSeenAtMs() != usedClock.now.UnixMilli() { - t.Fatalf("heartbeat should refresh last_seen only: user=%+v now=%d", user, usedClock.now.UnixMilli()) - } - if after := countPendingOutboxType(t, repository, "RoomUserJoined"); after != beforeJoinEvents { - t.Fatalf("heartbeat must not create join outbox: before=%d after=%d", beforeJoinEvents, after) - } - - if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.NotFound) { - t.Fatalf("heartbeat must not create missing presence, got %v", err) - } -} - -// TestLeaveRoomReleasesSeatAndBlocksGuards 验证离房同时移除 presence 和麦位占用。 -func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-leave-seat" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - leaveResp, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}) - if err != nil { - t.Fatalf("LeaveRoom failed: %v", err) - } - if findRoomUser(leaveResp.GetRoom(), 2) != nil { - t.Fatalf("left user should not remain in presence: %+v", leaveResp.GetRoom().GetOnlineUsers()) - } - if seat := findSeat(leaveResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 { - t.Fatalf("leave should release seat: %+v", seat) - } - if got := countPendingMicDownReason(t, repository, "leave"); got != 1 { - t.Fatalf("LeaveRoom should write one RoomMicChanged/down reason=leave, got %d", got) - } - - speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2}) - if err != nil { - t.Fatalf("CheckSpeakPermission failed: %v", err) - } - if speakResp.GetAllowed() || speakResp.GetReason() != "not_in_room" { - t.Fatalf("unexpected speak response after leave: %+v", speakResp) - } -} - -// TestRoomRecoveryAfterLeaseTakeover 验证 lease 过期后新节点通过 snapshot + command log 恢复房间。 -func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - syncPublisherA := &fakeSyncPublisher{} - outboxPublisher := &fakeOutboxPublisher{} - serviceA := newRoomService("node-a", 2, directory, repository, syncPublisherA, outboxPublisher) - - roomID := "room-recover" - - // node-a 先创建并推进房间状态,snapshotEveryN=2 会让恢复需要结合快照和命令日志。 - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - Role: "audience", - }); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - if _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - GiftId: "rose", - GiftCount: 1, - }); err != nil { - t.Fatalf("SendGift failed: %v", err) - } - - // 强制过期模拟 node-a 故障后 Redis lease 失效。 - directory.ForceExpire(roomRouteKeyForTest(roomID)) - - // node-b 收到命令时应先接管 lease,再恢复 Room Cell,而不是返回 room not found。 - serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, outboxPublisher) - - joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 3), - Role: "audience", - }) - if err != nil { - t.Fatalf("JoinRoom on takeover node failed: %v", err) - } - - if got := joinResp.GetRoom().GetHeat(); got != 10 { - t.Fatalf("recovered heat mismatch: got %d want 10", got) - } - - if len(joinResp.GetRoom().GetGiftRank()) != 1 || joinResp.GetRoom().GetGiftRank()[0].GetUserId() != 1 { - t.Fatalf("recovered rank mismatch: %+v", joinResp.GetRoom().GetGiftRank()) - } - - lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID)) - if err != nil || !exists { - t.Fatalf("Lookup failed: %v exists=%v", err, exists) - } - - if lease.NodeID != "node-b" { - t.Fatalf("lease takeover failed: owner=%s", lease.NodeID) - } -} - -func TestLeaseFencingRejectsOldOwnerAfterSlowWalletCommand(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - syncPublisherA := &fakeSyncPublisher{} - outboxPublisher := &fakeOutboxPublisher{} - serviceB := newRoomService("node-b", 1, directory, repository, &fakeSyncPublisher{}, outboxPublisher) - serviceA := roomservice.New(roomservice.Config{ - NodeID: "node-a", - LeaseTTL: 10 * time.Second, - RankLimit: 20, - SnapshotEveryN: 1, - }, directory, repository, takeoverWallet{ - roomID: "room-lease-fencing", - directory: directory, - takeoverSvc: serviceB, - }, syncPublisherA, outboxPublisher) - - roomID := "room-lease-fencing" - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "social", RoomName: "Fence Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - TargetUserId: 1, - GiftId: "rose", - GiftCount: 1, - }) - if !xerr.IsCode(err, xerr.Unavailable) { - t.Fatalf("old owner should fail fencing before room commit, got %v", err) - } - - joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) - if err != nil { - t.Fatalf("new owner should keep serving room: %v", err) - } - if joinResp.GetRoom().GetHeat() != 0 { - t.Fatalf("fenced SendGift must not update room heat: %+v", joinResp.GetRoom()) - } -} - -// TestGuardRecoveryReplaysCommandsAfterSnapshot 验证无内存 Cell 的守卫查询会补回快照后的 command log。 -func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - serviceA := newRoomService("node-a", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - roomID := "room-guard-recover" - - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if _, err := serviceA.MuteUser(ctx, &roomv1.MuteUserRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - Muted: true, - }); err != nil { - t.Fatalf("MuteUser failed: %v", err) - } - - // 新服务没有内存 Cell;CheckSpeakPermission 必须从 v2 快照后回放 v3 禁言命令。 - serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - speakResp, err := serviceB.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 2}) - if err != nil { - t.Fatalf("CheckSpeakPermission failed: %v", err) - } - if speakResp.GetAllowed() || speakResp.GetReason() != "user_muted" { - t.Fatalf("guard did not replay mute command: %+v", speakResp) - } -} - -// TestSweepStalePresenceRemovesUserAndReleasesSeat 验证 stale worker 复用离房链路清理断线用户。 -func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - syncPublisher := &fakeSyncPublisher{} - svc := newRoomServiceWithClock("node-a", 1, directory, repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-stale" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - usedClock.now = usedClock.now.Add(30 * time.Second) - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil { - t.Fatalf("refresh owner JoinRoom failed: %v", err) - } - - usedClock.now = usedClock.now.Add(40 * time.Second) - if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil { - t.Fatalf("RoomHeartbeat owner before stale sweep failed: %v", err) - } - if err := svc.SweepStalePresence(ctx); err != nil { - t.Fatalf("SweepStalePresence failed: %v", err) - } - - presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2}) - if err != nil { - t.Fatalf("VerifyRoomPresence failed: %v", err) - } - if presenceResp.GetPresent() || presenceResp.GetReason() != "not_in_room" { - t.Fatalf("stale user should be removed: %+v", presenceResp) - } - - snapshot, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}) - if err != nil { - t.Fatalf("JoinRoom after stale sweep failed: %v", err) - } - if seat := findSeat(snapshot.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 { - t.Fatalf("stale sweep should release seat: %+v", seat) - } - if !hasSyncEventReason(syncPublisher.events, "stale_timeout") { - t.Fatalf("stale sweep should publish room_user_left with reason: %+v", syncPublisher.events) - } -} - -func TestSweepStalePresenceSkipsRoomAfterLeaseTakeover(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-stale-lease-takeover" - - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - - usedClock.now = usedClock.now.Add(2 * time.Minute) - directory.ForceExpire(roomRouteKeyForTest(roomID)) - if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { - t.Fatalf("JoinRoom takeover failed: %v", err) - } - if err := serviceA.SweepStalePresence(ctx); err != nil { - t.Fatalf("old owner stale sweep failed: %v", err) - } - - joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) - if err != nil { - t.Fatalf("JoinRoom on current owner failed: %v", err) - } - if user := findRoomUser(joinResp.GetRoom(), 2); user == nil { - t.Fatalf("old owner stale sweep must not remove users after takeover: %+v", joinResp.GetRoom().GetOnlineUsers()) - } -} - -func TestSweepMicPublishTimeoutSkipsRoomAfterLeaseTakeover(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)} - serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute) - roomID := "room-mic-timeout-lease-takeover" - - if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 10, Mode: "voice", RoomName: "Test Room"}); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil { - t.Fatalf("JoinRoom user2 failed: %v", err) - } - micResp, err := serviceA.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}) - if err != nil { - t.Fatalf("MicUp failed: %v", err) - } - - usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs()) - directory.ForceExpire(roomRouteKeyForTest(roomID)) - if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil { - t.Fatalf("JoinRoom takeover failed: %v", err) - } - if err := serviceA.SweepMicPublishTimeouts(ctx); err != nil { - t.Fatalf("old owner mic timeout sweep failed: %v", err) - } - - joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)}) - if err != nil { - t.Fatalf("JoinRoom on current owner failed: %v", err) - } - if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetPublishState() != "pending_publish" { - t.Fatalf("old owner mic timeout sweep must not clear current owner's seat: seat=%+v mic=%+v", seat, micResp) - } -} - -// TestRoomOutboxCompensationWhenSyncPublishFails 验证同步广播失败不回滚房间状态,outbox 可补偿。 -func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - directory := router.NewMemoryDirectory() - syncPublisher := &fakeSyncPublisher{fail: true} - outboxPublisher := &fakeOutboxPublisher{} - svc := newRoomService("node-a", 1, directory, repository, syncPublisher, outboxPublisher) - - roomID := "room-outbox" - - // 创建和进房先建立可送礼的基本房间态。 - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - Role: "audience", - }); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - TargetUserId: 2, - GiftId: "car", - GiftCount: 1, - }); err != nil { - t.Fatalf("SendGift failed: %v", err) - } - - // fakeSyncPublisher 失败后,房间事件仍应作为 pending outbox 记录保留。 - pendingBefore, err := repository.ListPendingOutbox(ctx, 100) - if err != nil { - t.Fatalf("ListPendingOutbox failed: %v", err) - } - - if len(pendingBefore) == 0 { - t.Fatalf("expected pending outbox records before compensation") - } - - // 补偿 worker 成功投递后 fakeOutboxPublisher 应记录事件信封。 - if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil { - t.Fatalf("ProcessPendingOutbox failed: %v", err) - } - - if len(outboxPublisher.envelopes) == 0 { - t.Fatalf("expected compensated outbox publish") - } - if remaining, err := repository.ListPendingOutbox(ctx, 100); err != nil { - t.Fatalf("ListPendingOutbox after compensation failed: %v", err) - } else if len(remaining) != 0 { - t.Fatalf("expected all pending outbox records to be marked delivered, got %d", len(remaining)) - } -} - -func TestOutboxWorkerRetriesFailedPublishAndLaterMarksDelivered(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - outboxPublisher := &fakeOutboxPublisher{failCount: 1, err: errors.New("temporary im failure")} - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher) - - record, err := outbox.Build("room-retry", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{ - SenderUserId: 1, - TargetUserId: 2, - GiftId: "car", - GiftCount: 1, - GiftValue: 100, - }) - if err != nil { - t.Fatalf("Build outbox failed: %v", err) - } - if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil { - t.Fatalf("SaveOutbox failed: %v", err) - } - - options := roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second, InitialBackoff: time.Millisecond, MaxBackoff: time.Millisecond, MaxRetryCount: 3} - if err := svc.ProcessPendingOutbox(ctx, options); err != nil { - t.Fatalf("first ProcessPendingOutbox should keep batch alive after publish failure: %v", err) - } - failed, exists := repository.OutboxRecord(record.EventID) - if !exists { - t.Fatalf("outbox record missing after failure") - } - if failed.Status != outbox.StatusRetryable || failed.RetryCount != 1 || failed.NextRetryAtMS <= time.Now().Add(-time.Second).UnixMilli() || !strings.Contains(failed.LastError, "temporary im failure") { - t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed) - } - - time.Sleep(2 * time.Millisecond) - if err := svc.ProcessPendingOutbox(ctx, options); err != nil { - t.Fatalf("second ProcessPendingOutbox failed: %v", err) - } - delivered, exists := repository.OutboxRecord(record.EventID) - if !exists { - t.Fatalf("outbox record missing after success") - } - if delivered.Status != outbox.StatusDelivered || delivered.LastError != "" || len(outboxPublisher.envelopes) != 1 { - t.Fatalf("second publish should mark record delivered: record=%+v envelopes=%d", delivered, len(outboxPublisher.envelopes)) - } -} - -func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - - record, err := outbox.Build("room-claim", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{ - SenderUserId: 1, - TargetUserId: 2, - GiftId: "rose", - GiftCount: 1, - GiftValue: 10, - }) - if err != nil { - t.Fatalf("Build outbox failed: %v", err) - } - if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil { - t.Fatalf("SaveOutbox failed: %v", err) - } - - claimedA, err := repository.ClaimPendingOutbox(ctx, "worker-a", 10, time.Now().Add(time.Minute).UnixMilli()) - if err != nil { - t.Fatalf("worker-a claim failed: %v", err) - } - if len(claimedA) != 1 || claimedA[0].Status != outbox.StatusDelivering || claimedA[0].WorkerID != "worker-a" { - t.Fatalf("worker-a should claim one delivering record: %+v", claimedA) - } - claimedB, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli()) - if err != nil { - t.Fatalf("worker-b claim failed: %v", err) - } - if len(claimedB) != 0 { - t.Fatalf("worker-b should not claim worker-a locked record: %+v", claimedB) - } - - if err := repository.MarkOutboxRetryable(ctx, record.EventID, "temporary failure", time.Now().UnixMilli()); err != nil { - t.Fatalf("MarkOutboxRetryable failed: %v", err) - } - claimedRetry, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute).UnixMilli()) - if err != nil { - t.Fatalf("retry claim failed: %v", err) - } - if len(claimedRetry) != 1 || claimedRetry[0].Status != outbox.StatusDelivering || claimedRetry[0].RetryCount != 1 { - t.Fatalf("retryable record should be claimable again: %+v", claimedRetry) - } -} - -func TestOutboxWorkerMarksDeadLetterAfterRetryLimit(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - outboxPublisher := &fakeOutboxPublisher{failCount: 10, err: errors.New("permanent im failure")} - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, outboxPublisher) - - record, err := outbox.Build("room-dead", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{ - SenderUserId: 1, - TargetUserId: 2, - GiftId: "rocket", - GiftCount: 1, - GiftValue: 100, - }) - if err != nil { - t.Fatalf("Build outbox failed: %v", err) - } - record.RetryCount = 2 - if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil { - t.Fatalf("SaveOutbox failed: %v", err) - } - - if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 10, PublishTimeout: time.Second, MaxRetryCount: 2}); err != nil { - t.Fatalf("ProcessPendingOutbox failed: %v", err) - } - dead, exists := repository.OutboxRecord(record.EventID) - if !exists || dead.Status != outbox.StatusFailed || !strings.Contains(dead.LastError, "retry limit") { - t.Fatalf("event should be dead-lettered: exists=%v record=%+v", exists, dead) - } - if len(outboxPublisher.envelopes) != 0 { - t.Fatalf("dead-lettered record must not be published again") - } -} - -func TestRoomEventStaysPendingUntilOutboxWorkerDelivers(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - syncPublisher := &fakeSyncPublisher{} - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}) - roomID := "room-sync-delivered" - - if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 1), - SeatCount: 10, - Mode: "social", - RoomName: "Test Room", - }); err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{ - Meta: roomservice.NewRequestMeta(roomID, 2), - }); err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - if len(syncPublisher.events) != 0 { - t.Fatalf("room events must not be synchronously published: %+v", syncPublisher.events) - } - - records, err := repository.ListPendingOutbox(ctx, 100) - if err != nil { - t.Fatalf("ListPendingOutbox failed: %v", err) - } - if len(records) == 0 { - t.Fatalf("expected pending room event before worker delivery") - } - if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{BatchSize: 100, PublishTimeout: time.Second}); err != nil { - t.Fatalf("ProcessPendingOutbox failed: %v", err) - } - delivered, exists := repository.OutboxRecord(records[0].EventID) - if !exists || delivered.Status != outbox.StatusDelivered { - t.Fatalf("outbox worker should mark event delivered: exists=%v record=%+v", exists, delivered) - } -} - -func TestRunOutboxWorkerExitsOnCancel(t *testing.T) { - repository := mysqltest.NewRepository(t) - svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}) - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - - go func() { - defer close(done) - svc.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{ - PollInterval: 10 * time.Millisecond, - BatchSize: 10, - PublishTimeout: time.Second, - }) - }() - cancel() - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("outbox worker did not exit after cancel") - } -} - -func countPendingOutboxType(t *testing.T, repository roomservice.Repository, eventType string) int { - t.Helper() - - records, err := repository.ListPendingOutbox(context.Background(), 0) - if err != nil { - t.Fatalf("ListPendingOutbox failed: %v", err) - } - - count := 0 - for _, record := range records { - if record.EventType == eventType { - count++ - } - } - - return count -} - -func countPendingMicDownReason(t *testing.T, repository roomservice.Repository, reason string) int { - t.Helper() - - records, err := repository.ListPendingOutbox(context.Background(), 0) - if err != nil { - t.Fatalf("ListPendingOutbox failed: %v", err) - } - - count := 0 - for _, record := range records { - if record.EventType != "RoomMicChanged" || record.Envelope == nil { - continue - } - var body roomeventsv1.RoomMicChanged - if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil { - t.Fatalf("decode RoomMicChanged failed: %v", err) - } - if body.GetAction() == "down" && body.GetReason() == reason { - count++ - } - } - - return count -} - -func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser { - for _, user := range snapshot.GetOnlineUsers() { - if user.GetUserId() == userID { - return user - } - } - - return nil -} - -func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState { - for _, seat := range snapshot.GetMicSeats() { - if seat.GetSeatNo() == seatNo { - return seat - } - } - - return nil -} - -func containsInt64(values []int64, target int64) bool { - return slices.Contains(values, target) -} - -//go:fix inline -func stringPtr(value string) *string { - return new(value) -} - -//go:fix inline -func int32Ptr(value int32) *int32 { - return new(value) -} - -func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool { - for _, event := range events { - if event.EventType == "room_user_left" && event.Attributes["reason"] == reason { - return true - } - } - - return false -} - -func hasSyncEventType(events []tencentim.RoomEvent, eventType string) bool { - for _, event := range events { - if event.EventType == eventType { - return true - } - } - - return false -} diff --git a/services/room-service/internal/router/directory.go b/services/room-service/internal/router/directory.go index b39a0bf7..ea839f89 100644 --- a/services/room-service/internal/router/directory.go +++ b/services/room-service/internal/router/directory.go @@ -21,6 +21,24 @@ type Lease struct { ExpiresAt time.Time } +// NodeRegistration 记录一个 room-service 实例可被其他实例直连的内网地址。 +type NodeRegistration struct { + // NodeID 必须和 Redis lease 中的 owner node_id 完全一致。 + NodeID string + // GRPCAddr 是实例私网 gRPC 地址,必须指向单个实例,不能写 room-service 负载均衡地址。 + GRPCAddr string + // UpdatedAt 是最近一次心跳写入时间,便于排查节点注册是否卡住。 + UpdatedAt time.Time + // ExpiresAt 是节点注册过期时间;过期节点不能作为 owner 转发目标。 + ExpiresAt time.Time +} + +// ValidAt 判断节点注册在指定时间是否仍可作为转发目标。 +func (n NodeRegistration) ValidAt(now time.Time) bool { + // 没有地址或过期时间的注册不能被使用,避免把请求转发到空地址或旧实例。 + return n.NodeID != "" && n.GRPCAddr != "" && !n.ExpiresAt.IsZero() && now.Before(n.ExpiresAt) +} + // ValidAt 判断租约在指定时刻是否仍有效。 func (l Lease) ValidAt(now time.Time) bool { // 零值过期时间永远无效,避免空 lease 被误认为可继续持有。 @@ -35,22 +53,37 @@ type Directory interface { EnsureOwner(ctx context.Context, roomID string, nodeID string, now time.Time, ttl time.Duration) (Lease, error) // VerifyOwner 校验当前节点和 lease token 仍然持有执行权,不续租也不接管。 VerifyOwner(ctx context.Context, roomID string, nodeID string, leaseToken string, now time.Time) (bool, error) + // ReleaseOwner 在节点安全下线后释放自己仍持有的 owner lease,让新节点无需等待 TTL 才能恢复。 + ReleaseOwner(ctx context.Context, roomID string, nodeID string) (bool, error) // ForceExpire 只用于测试模拟租约过期。 ForceExpire(roomID string) } +// NodeRegistry 维护 room-service node_id 到实例私网 gRPC 地址的短租约。 +type NodeRegistry interface { + // RegisterNode 写入或续约当前节点地址;ttl 过期后其他节点不能继续转发到该实例。 + RegisterNode(ctx context.Context, node NodeRegistration, ttl time.Duration) error + // LookupNode 查询一个 owner node_id 的实例地址。 + LookupNode(ctx context.Context, nodeID string) (NodeRegistration, bool, error) + // UnregisterNode 在实例安全退出后删除注册,避免转发到已停止进程。 + UnregisterNode(ctx context.Context, nodeID string) error +} + // MemoryDirectory 是首版测试与本地运行使用的内存目录实现。 type MemoryDirectory struct { // mu 保护 leases,测试里可能用多个服务实例模拟接管。 mu sync.Mutex // leases 保存 room_id -> lease。 leases map[string]Lease + // nodes 保存 node_id -> 注册地址,供 transport 层 owner 转发测试复用。 + nodes map[string]NodeRegistration } // NewMemoryDirectory 初始化空的内存目录。 func NewMemoryDirectory() *MemoryDirectory { return &MemoryDirectory{ leases: make(map[string]Lease), + nodes: make(map[string]NodeRegistration), } } @@ -112,6 +145,20 @@ func (d *MemoryDirectory) VerifyOwner(_ context.Context, roomID string, nodeID s return current.NodeID == nodeID && current.LeaseToken == leaseToken && current.ValidAt(now), nil } +// ReleaseOwner 删除当前节点持有的内存 lease;其他节点持有时保持不变。 +func (d *MemoryDirectory) ReleaseOwner(_ context.Context, roomID string, nodeID string) (bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + + current, ok := d.leases[roomID] + if !ok || current.NodeID != nodeID { + return false, nil + } + + delete(d.leases, roomID) + return true, nil +} + // ForceExpire 只用于测试时模拟租约自然过期。 func (d *MemoryDirectory) ForceExpire(roomID string) { d.mu.Lock() @@ -126,3 +173,40 @@ func (d *MemoryDirectory) ForceExpire(roomID string) { lease.ExpiresAt = time.Unix(0, 0) d.leases[roomID] = lease } + +// RegisterNode 写入测试用节点地址。 +func (d *MemoryDirectory) RegisterNode(_ context.Context, node NodeRegistration, ttl time.Duration) error { + d.mu.Lock() + defer d.mu.Unlock() + + now := time.Now().UTC() + if ttl <= 0 { + ttl = 30 * time.Second + } + node.UpdatedAt = now + node.ExpiresAt = now.Add(ttl) + d.nodes[node.NodeID] = node + return nil +} + +// LookupNode 查询测试用节点地址,过期注册按不存在处理。 +func (d *MemoryDirectory) LookupNode(_ context.Context, nodeID string) (NodeRegistration, bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + + node, ok := d.nodes[nodeID] + if !ok || !node.ValidAt(time.Now().UTC()) { + return NodeRegistration{}, false, nil + } + + return node, true, nil +} + +// UnregisterNode 删除测试用节点地址。 +func (d *MemoryDirectory) UnregisterNode(_ context.Context, nodeID string) error { + d.mu.Lock() + defer d.mu.Unlock() + + delete(d.nodes, nodeID) + return nil +} diff --git a/services/room-service/internal/router/redis_directory.go b/services/room-service/internal/router/redis_directory.go index b0d0773c..db123de0 100644 --- a/services/room-service/internal/router/redis_directory.go +++ b/services/room-service/internal/router/redis_directory.go @@ -4,7 +4,9 @@ package router import ( "context" "encoding/json" + "fmt" "strconv" + "strings" "time" "github.com/redis/go-redis/v9" @@ -14,6 +16,9 @@ import ( // roomRouteKeyPrefix 是生产房间 owner lease 的 Redis key 前缀。 const roomRouteKeyPrefix = "room:route:" +// roomNodeKeyPrefix 保存 room-service 节点的实例直连地址,owner 转发只读取该短租约。 +const roomNodeKeyPrefix = "room:node:" + // healthLeaseKey 是 ready 探针专用 key,避免探针污染真实房间路由。 const healthLeaseKey = "room:healthcheck:lease" @@ -70,6 +75,25 @@ end return 0 ` +const releaseOwnerScript = ` +local current = redis.call("GET", KEYS[1]) +if not current then + return 0 +end + +local ok, decoded = pcall(cjson.decode, current) +if not ok then + return 0 +end + +if decoded["node_id"] == ARGV[1] then + redis.call("DEL", KEYS[1]) + return 1 +end + +return 0 +` + // RedisDirectory 用 Redis 保存 room_id 到执行节点的带 TTL 租约。 type RedisDirectory struct { // client 是 go-redis 客户端,连接生命周期由 app.App 关闭。 @@ -84,6 +108,14 @@ type redisLeaseValue struct { ExpiresAt int64 `json:"expires_at_ms"` } +// redisNodeValue 是 Redis 中的节点注册 JSON,字段保持可读以便 redis-cli 排查。 +type redisNodeValue struct { + NodeID string `json:"node_id"` + GRPCAddr string `json:"grpc_addr"` + UpdatedAt int64 `json:"updated_at_ms"` + ExpiresAt int64 `json:"expires_at_ms"` +} + // NewRedisDirectory 创建 Redis 目录实现。 func NewRedisDirectory(client *redis.Client) *RedisDirectory { return &RedisDirectory{client: client} @@ -222,17 +254,80 @@ func (d *RedisDirectory) VerifyOwner(ctx context.Context, roomID string, nodeID return value == 1, nil } +// ReleaseOwner 在节点安全下线后删除自己持有的 route lease。 +func (d *RedisDirectory) ReleaseOwner(ctx context.Context, roomID string, nodeID string) (bool, error) { + value, err := d.client.Eval(ctx, releaseOwnerScript, []string{routeKey(roomID)}, nodeID).Int() + if err != nil { + return false, err + } + + return value == 1, nil +} + // ForceExpire 只服务测试,生产启动路径不会调用。 func (d *RedisDirectory) ForceExpire(roomID string) { // Redis 实现直接删除 key 来模拟 lease 过期,避免等待真实 TTL。 _ = d.client.Del(context.Background(), routeKey(roomID)).Err() } +// RegisterNode 写入当前 room-service 实例地址;TTL 过期后 owner 转发不会再使用该节点。 +func (d *RedisDirectory) RegisterNode(ctx context.Context, node NodeRegistration, ttl time.Duration) error { + if ttl <= 0 { + ttl = 30 * time.Second + } + node.NodeID = strings.TrimSpace(node.NodeID) + node.GRPCAddr = strings.TrimSpace(node.GRPCAddr) + if node.NodeID == "" || node.GRPCAddr == "" { + return fmt.Errorf("node_id and grpc_addr are required") + } + now := time.Now().UTC() + node.UpdatedAt = now + node.ExpiresAt = now.Add(ttl) + payload, err := encodeNode(node) + if err != nil { + return err + } + + return d.client.Set(ctx, nodeKey(node.NodeID), payload, ttl).Err() +} + +// LookupNode 查询 owner node_id 对应的实例地址。 +func (d *RedisDirectory) LookupNode(ctx context.Context, nodeID string) (NodeRegistration, bool, error) { + value, err := d.client.Get(ctx, nodeKey(nodeID)).Result() + if err != nil { + if err == redis.Nil { + return NodeRegistration{}, false, nil + } + + return NodeRegistration{}, false, err + } + + node, err := decodeNode(value) + if err != nil { + return NodeRegistration{}, false, err + } + if !node.ValidAt(time.Now().UTC()) { + return NodeRegistration{}, false, nil + } + + return node, true, nil +} + +// UnregisterNode 删除节点注册;下线节点停止接受 owner 转发前必须调用。 +func (d *RedisDirectory) UnregisterNode(ctx context.Context, nodeID string) error { + return d.client.Del(ctx, nodeKey(nodeID)).Err() +} + func routeKey(roomID string) string { // 路由 key 只由房间 ID 决定,所有节点必须计算出同一个 Redis key。 return roomRouteKeyPrefix + roomID } +func nodeKey(nodeID string) string { + // 节点 key 使用 node_id,不包含 app_code;一个实例可以服务多个 App 的房间。 + return roomNodeKeyPrefix + strings.TrimSpace(nodeID) +} + func encodeLease(lease Lease) (string, error) { // 使用 JSON 而不是二进制,方便线上通过 redis-cli 直接排查 owner。 payload, err := json.Marshal(redisLeaseValue{ @@ -248,6 +343,20 @@ func encodeLease(lease Lease) (string, error) { return string(payload), nil } +func encodeNode(node NodeRegistration) (string, error) { + payload, err := json.Marshal(redisNodeValue{ + NodeID: strings.TrimSpace(node.NodeID), + GRPCAddr: strings.TrimSpace(node.GRPCAddr), + UpdatedAt: node.UpdatedAt.UnixMilli(), + ExpiresAt: node.ExpiresAt.UnixMilli(), + }) + if err != nil { + return "", err + } + + return string(payload), nil +} + func decodeLease(value string) (Lease, error) { var payload redisLeaseValue if err := json.Unmarshal([]byte(value), &payload); err != nil { @@ -263,3 +372,17 @@ func decodeLease(value string) (Lease, error) { ExpiresAt: time.UnixMilli(payload.ExpiresAt), }, nil } + +func decodeNode(value string) (NodeRegistration, error) { + var payload redisNodeValue + if err := json.Unmarshal([]byte(value), &payload); err != nil { + return NodeRegistration{}, err + } + + return NodeRegistration{ + NodeID: strings.TrimSpace(payload.NodeID), + GRPCAddr: strings.TrimSpace(payload.GRPCAddr), + UpdatedAt: time.UnixMilli(payload.UpdatedAt), + ExpiresAt: time.UnixMilli(payload.ExpiresAt), + }, nil +} diff --git a/services/room-service/internal/transport/grpc/owner_forwarder.go b/services/room-service/internal/transport/grpc/owner_forwarder.go new file mode 100644 index 00000000..984bdb36 --- /dev/null +++ b/services/room-service/internal/transport/grpc/owner_forwarder.go @@ -0,0 +1,172 @@ +package grpc + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "hyapp/pkg/xerr" + roomservice "hyapp/services/room-service/internal/room/service" + "hyapp/services/room-service/internal/router" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + roomv1 "hyapp.local/api/proto/room/v1" +) + +const ( + // ownerForwardDepthHeader 防止错误注册到负载均衡地址后出现递归转发。 + ownerForwardDepthHeader = "x-hyapp-room-owner-forward-depth" + // maxOwnerForwardDepth 允许一次中转;超过后让本节点走本地逻辑并返回真实 owner 冲突。 + maxOwnerForwardDepth = 1 + // defaultOwnerForwardTimeout 限制转发占用调用方请求预算的最长时间。 + defaultOwnerForwardTimeout = 2 * time.Second +) + +type ownerForwarder struct { + // nodeID 是当前 room-service 实例 ID,用于判断自己是否就是 owner。 + nodeID string + // directory 保存 room_id -> owner node_id 的短租约。 + directory router.Directory + // registry 保存 owner node_id -> 实例 gRPC 地址的短租约。 + registry router.NodeRegistry + // timeout 是单次 owner 转发的额外 deadline 上限。 + timeout time.Duration + + mu sync.Mutex + conns map[string]forwardConn +} + +type forwardConn struct { + addr string + conn *grpc.ClientConn +} + +func newOwnerForwarder(nodeID string, directory router.Directory, registry router.NodeRegistry, timeout time.Duration) *ownerForwarder { + if timeout <= 0 { + timeout = defaultOwnerForwardTimeout + } + return &ownerForwarder{ + nodeID: strings.TrimSpace(nodeID), + directory: directory, + registry: registry, + timeout: timeout, + conns: make(map[string]forwardConn), + } +} + +func forwardCommandToOwner[T any](f *ownerForwarder, ctx context.Context, appCode string, roomID string, call func(context.Context, roomv1.RoomCommandServiceClient) (T, error)) (T, bool, error) { + return forwardToOwner(ctx, f, appCode, roomID, func(callCtx context.Context, conn *grpc.ClientConn) (T, error) { + return call(callCtx, roomv1.NewRoomCommandServiceClient(conn)) + }) +} + +func forwardGuardToOwner[T any](f *ownerForwarder, ctx context.Context, appCode string, roomID string, call func(context.Context, roomv1.RoomGuardServiceClient) (T, error)) (T, bool, error) { + return forwardToOwner(ctx, f, appCode, roomID, func(callCtx context.Context, conn *grpc.ClientConn) (T, error) { + return call(callCtx, roomv1.NewRoomGuardServiceClient(conn)) + }) +} + +func forwardQueryToOwner[T any](f *ownerForwarder, ctx context.Context, appCode string, roomID string, call func(context.Context, roomv1.RoomQueryServiceClient) (T, error)) (T, bool, error) { + return forwardToOwner(ctx, f, appCode, roomID, func(callCtx context.Context, conn *grpc.ClientConn) (T, error) { + return call(callCtx, roomv1.NewRoomQueryServiceClient(conn)) + }) +} + +func forwardToOwner[T any](ctx context.Context, f *ownerForwarder, appCode string, roomID string, call func(context.Context, *grpc.ClientConn) (T, error)) (T, bool, error) { + var zero T + if f == nil || f.directory == nil || f.registry == nil || strings.TrimSpace(roomID) == "" { + return zero, false, nil + } + if ownerForwardDepth(ctx) >= maxOwnerForwardDepth { + // 已经中转过的请求继续走本地逻辑,避免注册错误造成递归 RPC。 + return zero, false, nil + } + + now := time.Now().UTC() + lease, exists, err := f.directory.Lookup(ctx, roomservice.RuntimeRoomKey(appCode, roomID)) + if err != nil { + return zero, true, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room owner lookup failed")) + } + if !exists || !lease.ValidAt(now) || lease.NodeID == f.nodeID { + return zero, false, nil + } + + node, exists, err := f.registry.LookupNode(ctx, lease.NodeID) + if err != nil { + return zero, true, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room owner node lookup failed")) + } + if !exists || !node.ValidAt(now) { + return zero, true, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "room owner node is not registered")) + } + + conn, err := f.connForNode(ctx, node) + if err != nil { + return zero, true, xerr.ToGRPCError(xerr.New(xerr.Unavailable, fmt.Sprintf("room owner node dial failed: %v", err))) + } + + callCtx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + callCtx = metadata.AppendToOutgoingContext(callCtx, ownerForwardDepthHeader, strconv.Itoa(ownerForwardDepth(ctx)+1)) + result, err := call(callCtx, conn) + return result, true, err +} + +func (f *ownerForwarder) connForNode(_ context.Context, node router.NodeRegistration) (*grpc.ClientConn, error) { + f.mu.Lock() + defer f.mu.Unlock() + + if cached, ok := f.conns[node.NodeID]; ok && cached.addr == node.GRPCAddr { + return cached.conn, nil + } + if cached, ok := f.conns[node.NodeID]; ok && cached.conn != nil { + // 节点地址变化通常来自实例重建;关闭旧连接,避免继续向旧进程转发。 + _ = cached.conn.Close() + } + + conn, err := grpc.Dial(node.GRPCAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, err + } + f.conns[node.NodeID] = forwardConn{addr: node.GRPCAddr, conn: conn} + return conn, nil +} + +func (f *ownerForwarder) close() { + if f == nil { + return + } + f.mu.Lock() + defer f.mu.Unlock() + + for nodeID, cached := range f.conns { + if cached.conn != nil { + _ = cached.conn.Close() + } + delete(f.conns, nodeID) + } +} + +func ownerForwardDepth(ctx context.Context) int { + var values []string + if incoming, ok := metadata.FromIncomingContext(ctx); ok { + values = incoming.Get(ownerForwardDepthHeader) + } + if len(values) == 0 { + if outgoing, ok := metadata.FromOutgoingContext(ctx); ok { + values = outgoing.Get(ownerForwardDepthHeader) + } + } + if len(values) == 0 { + return 0 + } + depth, err := strconv.Atoi(strings.TrimSpace(values[len(values)-1])) + if err != nil || depth < 0 { + return 0 + } + return depth +} diff --git a/services/room-service/internal/transport/grpc/server.go b/services/room-service/internal/transport/grpc/server.go index 35ca43c5..1c518fd9 100644 --- a/services/room-service/internal/transport/grpc/server.go +++ b/services/room-service/internal/transport/grpc/server.go @@ -3,11 +3,13 @@ package grpc import ( "context" + "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" roomservice "hyapp/services/room-service/internal/room/service" + "hyapp/services/room-service/internal/router" ) // Server 把 room-service 领域实现挂到 gRPC 接口上。 @@ -21,11 +23,29 @@ type Server struct { // svc 是领域服务入口,gRPC 层不保存任何房间状态。 svc *roomservice.Service + // ownerForwarder 在请求命中非 owner 节点时把房间命令转到当前 owner 实例。 + ownerForwarder *ownerForwarder +} + +// Option 调整 room-service gRPC 边界行为。 +type Option func(*Server) + +// WithOwnerForwarding 启用 room_id -> owner node -> instance addr 转发。 +func WithOwnerForwarding(nodeID string, directory router.Directory, registry router.NodeRegistry, timeout time.Duration) Option { + return func(s *Server) { + s.ownerForwarder = newOwnerForwarder(nodeID, directory, registry, timeout) + } } // NewServer 创建一个可注册到 gRPC 的 room server。 -func NewServer(svc *roomservice.Service) *Server { - return &Server{svc: svc} +func NewServer(svc *roomservice.Service, opts ...Option) *Server { + server := &Server{svc: svc} + for _, opt := range opts { + if opt != nil { + opt(server) + } + } + return server } // mapServiceResult 把领域错误固定转换为 gRPC status + ErrorInfo.reason。 @@ -44,6 +64,11 @@ func mapServiceResult[T any](result T, err error) (T, error) { func (s *Server) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) { // 创建房间会获取 Redis lease、写 MySQL meta/command/outbox/snapshot,并安装本地 Cell。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.CreateRoomResponse, error) { + return client.CreateRoom(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.CreateRoom(ctx, req)) } @@ -51,6 +76,11 @@ func (s *Server) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) func (s *Server) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) { // 房间资料和座位数变更必须经 Room Cell 串行提交,gRPC 层不直接修改快照。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.UpdateRoomProfileResponse, error) { + return client.UpdateRoomProfile(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.UpdateRoomProfile(ctx, req)) } @@ -58,6 +88,11 @@ func (s *Server) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomPr func (s *Server) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) { // JoinRoom 只维护 room-service presence,真实消息连接由腾讯云 IM SDK 处理。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.JoinRoomResponse, error) { + return client.JoinRoom(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.JoinRoom(ctx, req)) } @@ -65,6 +100,11 @@ func (s *Server) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*ro func (s *Server) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) { // 心跳只刷新已有业务 presence,不能隐式创建进房状态。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.RoomHeartbeatResponse, error) { + return client.RoomHeartbeat(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.RoomHeartbeat(ctx, req)) } @@ -72,6 +112,11 @@ func (s *Server) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatReq func (s *Server) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) { // LeaveRoom 不直接操作腾讯云 IM 连接,只修改房间业务态。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.LeaveRoomResponse, error) { + return client.LeaveRoom(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.LeaveRoom(ctx, req)) } @@ -79,6 +124,11 @@ func (s *Server) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (* func (s *Server) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (*roomv1.CloseRoomResponse, error) { // CloseRoom 统一关闭房间生命周期、清理 presence/麦位并写出系统事件。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.CloseRoomResponse, error) { + return client.CloseRoom(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.CloseRoom(ctx, req)) } @@ -86,6 +136,11 @@ func (s *Server) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (* func (s *Server) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) { // 麦位修改由 Room Cell 串行执行,gRPC 层不做状态判断。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.MicUpResponse, error) { + return client.MicUp(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.MicUp(ctx, req)) } @@ -93,6 +148,11 @@ func (s *Server) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.M func (s *Server) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) { // 下麦结果会进入 command log 和 outbox,失败不产生部分状态。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.MicDownResponse, error) { + return client.MicDown(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.MicDown(ctx, req)) } @@ -100,6 +160,11 @@ func (s *Server) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*room func (s *Server) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) { // 换位在领域层原子修改源麦位和目标麦位。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.ChangeMicSeatResponse, error) { + return client.ChangeMicSeat(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.ChangeMicSeat(ctx, req)) } @@ -107,6 +172,11 @@ func (s *Server) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatReq func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) { // 发流确认必须进入 Room Cell 串行状态机,避免旧 RTC 事件覆盖新 mic_session。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.ConfirmMicPublishingResponse, error) { + return client.ConfirmMicPublishing(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.ConfirmMicPublishing(ctx, req)) } @@ -114,6 +184,11 @@ func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMi func (s *Server) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) { // 服务端可见静音态必须经 Room Cell 提交,确保其他用户 UI 和系统消息一致。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.SetMicMuteResponse, error) { + return client.SetMicMute(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.SetMicMute(ctx, req)) } @@ -121,6 +196,11 @@ func (s *Server) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest) func (s *Server) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) { // 腾讯 RTC 回调已在 gateway 验签;房间状态变化仍必须由 Room Cell 串行提交。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.ApplyRTCEventResponse, error) { + return client.ApplyRTCEvent(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.ApplyRTCEvent(ctx, req)) } @@ -128,6 +208,11 @@ func (s *Server) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventReq func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) { // 锁麦权限和目标麦位状态由 Room Cell 判定。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.SetMicSeatLockResponse, error) { + return client.SetMicSeatLock(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.SetMicSeatLock(ctx, req)) } @@ -135,6 +220,11 @@ func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockR func (s *Server) SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) { // 公屏开关是 room-service 发言守卫的权威状态。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.SetChatEnabledResponse, error) { + return client.SetChatEnabled(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.SetChatEnabled(ctx, req)) } @@ -142,6 +232,11 @@ func (s *Server) SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledR func (s *Server) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) { // 管理员集合只能由 room-service 权限矩阵修改。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.SetRoomAdminResponse, error) { + return client.SetRoomAdmin(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.SetRoomAdmin(ctx, req)) } @@ -149,6 +244,11 @@ func (s *Server) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminReque func (s *Server) TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) { // 主持人转移由 Room Cell 串行提交,并进入 command log/outbox。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.TransferRoomHostResponse, error) { + return client.TransferRoomHost(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.TransferRoomHost(ctx, req)) } @@ -156,6 +256,11 @@ func (s *Server) TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomH func (s *Server) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) { // 禁言状态是腾讯云 IM 发言回调的权威来源。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.MuteUserResponse, error) { + return client.MuteUser(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.MuteUser(ctx, req)) } @@ -163,6 +268,11 @@ func (s *Server) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*ro func (s *Server) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) { // 踢人会修改 presence、ban 集合和麦位占用。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.KickUserResponse, error) { + return client.KickUser(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.KickUser(ctx, req)) } @@ -170,6 +280,11 @@ func (s *Server) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*ro func (s *Server) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) { // 解封只解除 ban,不恢复 presence、管理员身份或麦位。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.UnbanUserResponse, error) { + return client.UnbanUser(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.UnbanUser(ctx, req)) } @@ -177,6 +292,11 @@ func (s *Server) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (* func (s *Server) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) { // SendGift 先同步扣费,成功后才更新房间热度和本地榜。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.SendGiftResponse, error) { + return client.SendGift(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.SendGift(ctx, req)) } @@ -184,6 +304,11 @@ func (s *Server) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*ro func (s *Server) CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) { // 外部 IM 发公屏前调用该守卫,避免复制禁言和房间状态规则。 ctx = appcode.WithContext(ctx, req.GetAppCode()) + if resp, forwarded, err := forwardGuard(s, ctx, req.GetAppCode(), req.GetRoomId(), func(callCtx context.Context, client roomv1.RoomGuardServiceClient) (*roomv1.CheckSpeakPermissionResponse, error) { + return client.CheckSpeakPermission(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.CheckSpeakPermission(ctx, req)) } @@ -191,6 +316,11 @@ func (s *Server) CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpea func (s *Server) VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) { // 外部 IM 进群前调用该守卫,防止未进房或被踢用户收消息。 ctx = appcode.WithContext(ctx, req.GetAppCode()) + if resp, forwarded, err := forwardGuard(s, ctx, req.GetAppCode(), req.GetRoomId(), func(callCtx context.Context, client roomv1.RoomGuardServiceClient) (*roomv1.VerifyRoomPresenceResponse, error) { + return client.VerifyRoomPresence(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.VerifyRoomPresence(ctx, req)) } @@ -226,6 +356,11 @@ func (s *Server) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomR func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) { // 快照查询不刷新 presence,不替代 heartbeat,也不能让未进房用户读取完整房间态。 ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardQuery(s, ctx, req.GetMeta().GetAppCode(), req.GetRoomId(), func(callCtx context.Context, client roomv1.RoomQueryServiceClient) (*roomv1.GetRoomSnapshotResponse, error) { + return client.GetRoomSnapshot(callCtx, req) + }); forwarded { + return resp, err + } return mapServiceResult(s.svc.GetRoomSnapshot(ctx, req)) } @@ -242,3 +377,27 @@ func contextWithMetaApp(ctx context.Context, meta *roomv1.RequestMeta) context.C } return appcode.WithContext(ctx, meta.GetAppCode()) } + +func forwardCommand[T any](s *Server, ctx context.Context, meta *roomv1.RequestMeta, call func(context.Context, roomv1.RoomCommandServiceClient) (T, error)) (T, bool, error) { + var zero T + if s == nil || s.ownerForwarder == nil || meta == nil { + return zero, false, nil + } + return forwardCommandToOwner(s.ownerForwarder, ctx, meta.GetAppCode(), meta.GetRoomId(), call) +} + +func forwardGuard[T any](s *Server, ctx context.Context, appCode string, roomID string, call func(context.Context, roomv1.RoomGuardServiceClient) (T, error)) (T, bool, error) { + var zero T + if s == nil || s.ownerForwarder == nil { + return zero, false, nil + } + return forwardGuardToOwner(s.ownerForwarder, ctx, appCode, roomID, call) +} + +func forwardQuery[T any](s *Server, ctx context.Context, appCode string, roomID string, call func(context.Context, roomv1.RoomQueryServiceClient) (T, error)) (T, bool, error) { + var zero T + if s == nil || s.ownerForwarder == nil { + return zero, false, nil + } + return forwardQueryToOwner(s.ownerForwarder, ctx, appCode, roomID, call) +} diff --git a/services/room-service/internal/transport/grpc/server_test.go b/services/room-service/internal/transport/grpc/server_test.go index ee596d07..6da93c9c 100644 --- a/services/room-service/internal/transport/grpc/server_test.go +++ b/services/room-service/internal/transport/grpc/server_test.go @@ -2,18 +2,23 @@ package grpc import ( "context" + "net" + "os" "testing" "time" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" + grpcpkg "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" grpcstatus "google.golang.org/grpc/status" ) @@ -61,3 +66,172 @@ func TestCreateRoomMapsDomainErrorToGRPCReason(t *testing.T) { t.Fatalf("message mismatch: got %q", message) } } + +func TestOwnerForwardingUsesRedisRouteAndNodeRegistry(t *testing.T) { + redisAddr := os.Getenv("ROOM_SERVICE_REDIS_TEST_ADDR") + if redisAddr == "" { + t.Skip("set ROOM_SERVICE_REDIS_TEST_ADDR to run Redis-backed owner forwarding test") + } + ctx := context.Background() + redisClient, err := router.NewRedisClient(ctx, redisAddr, os.Getenv("ROOM_SERVICE_REDIS_TEST_PASSWORD"), 0) + if err != nil { + t.Fatalf("open redis directory failed: %v", err) + } + t.Cleanup(func() { _ = redisClient.Close() }) + directory := router.NewRedisDirectory(redisClient) + repository := mysqltest.NewRepository(t) + + addrA, stopA := startForwardingRoomServer(t, "node-forward-a", directory, repository) + defer stopA() + addrB, stopB := startForwardingRoomServer(t, "node-forward-b", directory, repository) + defer stopB() + + if err := directory.RegisterNode(ctx, router.NodeRegistration{NodeID: "node-forward-a", GRPCAddr: addrA}, time.Minute); err != nil { + t.Fatalf("register node a failed: %v", err) + } + if err := directory.RegisterNode(ctx, router.NodeRegistration{NodeID: "node-forward-b", GRPCAddr: addrB}, time.Minute); err != nil { + t.Fatalf("register node b failed: %v", err) + } + t.Cleanup(func() { + _ = directory.UnregisterNode(context.Background(), "node-forward-a") + _ = directory.UnregisterNode(context.Background(), "node-forward-b") + }) + + connA, err := grpcpkg.Dial(addrA, grpcpkg.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial node a failed: %v", err) + } + defer connA.Close() + connB, err := grpcpkg.Dial(addrB, grpcpkg.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("dial node b failed: %v", err) + } + defer connB.Close() + + roomID := "room-forward-test" + directory.ForceExpire(roomservice.RuntimeRoomKey(appcode.Default, roomID)) + t.Cleanup(func() { + directory.ForceExpire(roomservice.RuntimeRoomKey(appcode.Default, roomID)) + }) + createResp, err := roomv1.NewRoomCommandServiceClient(connA).CreateRoom(ctx, &roomv1.CreateRoomRequest{ + Meta: &roomv1.RequestMeta{ + RequestId: "req-forward-create", + CommandId: "cmd-forward-create", + ActorUserId: 1001, + RoomId: roomID, + SentAtMs: time.Now().UTC().UnixMilli(), + AppCode: appcode.Default, + }, + SeatCount: 10, + Mode: "voice", + RoomName: "Forward Room", + RoomShortId: "forward-short", + }) + if err != nil { + t.Fatalf("create room on node a failed: %v", err) + } + if createResp.GetRoom().GetRoomId() != roomID { + t.Fatalf("create room id mismatch: %+v", createResp.GetRoom()) + } + + lease, exists, err := directory.Lookup(ctx, roomservice.RuntimeRoomKey(appcode.Default, roomID)) + if err != nil || !exists { + t.Fatalf("lookup owner lease failed: exists=%v err=%v", exists, err) + } + if lease.NodeID != "node-forward-a" { + t.Fatalf("room should be owned by node a before forwarding, got %+v", lease) + } + + joinResp, err := roomv1.NewRoomCommandServiceClient(connB).JoinRoom(ctx, &roomv1.JoinRoomRequest{ + Meta: &roomv1.RequestMeta{ + RequestId: "req-forward-join", + CommandId: "cmd-forward-join", + ActorUserId: 1002, + RoomId: roomID, + SentAtMs: time.Now().UTC().UnixMilli(), + AppCode: appcode.Default, + }, + Role: "audience", + }) + if err != nil { + t.Fatalf("join room through node b should forward to owner: %v", err) + } + if findUser(joinResp.GetRoom(), 1002) == nil { + t.Fatalf("forwarded join did not update owner room snapshot: %+v", joinResp.GetRoom().GetOnlineUsers()) + } + + guardResp, err := roomv1.NewRoomGuardServiceClient(connB).CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{ + RoomId: roomID, + UserId: 1002, + AppCode: appcode.Default, + }) + if err != nil { + t.Fatalf("guard through node b should forward to owner: %v", err) + } + if !guardResp.GetAllowed() { + t.Fatalf("forwarded guard should allow joined user, got %+v", guardResp) + } + + snapshotResp, err := roomv1.NewRoomQueryServiceClient(connB).GetRoomSnapshot(ctx, &roomv1.GetRoomSnapshotRequest{ + Meta: &roomv1.RequestMeta{ + RequestId: "req-forward-snapshot", + ActorUserId: 1002, + RoomId: roomID, + AppCode: appcode.Default, + }, + RoomId: roomID, + ViewerUserId: 1002, + }) + if err != nil { + t.Fatalf("snapshot through node b should forward to owner: %v", err) + } + if snapshotResp.GetRoom().GetVersion() != joinResp.GetRoom().GetVersion() { + t.Fatalf("forwarded snapshot version mismatch: got %d want %d", snapshotResp.GetRoom().GetVersion(), joinResp.GetRoom().GetVersion()) + } +} + +func startForwardingRoomServer(t *testing.T, nodeID string, directory *router.RedisDirectory, repository roomservice.Repository) (string, func()) { + t.Helper() + + svc := roomservice.New(roomservice.Config{ + NodeID: nodeID, + LeaseTTL: 10 * time.Second, + RankLimit: 20, + SnapshotEveryN: 1, + }, directory, repository, fakeWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen forwarding test server failed: %v", err) + } + grpcServer := grpcpkg.NewServer() + roomServer := NewServer(svc, WithOwnerForwarding(nodeID, directory, directory, time.Second)) + roomv1.RegisterRoomCommandServiceServer(grpcServer, roomServer) + roomv1.RegisterRoomGuardServiceServer(grpcServer, roomServer) + roomv1.RegisterRoomQueryServiceServer(grpcServer, roomServer) + errCh := make(chan error, 1) + go func() { + errCh <- grpcServer.Serve(listener) + }() + stop := func() { + roomServer.ownerForwarder.close() + grpcServer.GracefulStop() + select { + case err := <-errCh: + if err != nil { + t.Fatalf("forwarding test server stopped with error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("forwarding test server did not stop") + } + } + return listener.Addr().String(), stop +} + +func findUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser { + for _, user := range snapshot.GetOnlineUsers() { + if user.GetUserId() == userID { + return user + } + } + return nil +} diff --git a/services/user-service/Dockerfile b/services/user-service/Dockerfile index c5f43451..ce5c08ed 100644 --- a/services/user-service/Dockerfile +++ b/services/user-service/Dockerfile @@ -30,7 +30,7 @@ COPY services/user-service/configs/config.docker.yaml /app/config.yaml USER appuser -EXPOSE 13005 +EXPOSE 13005 13105 ENTRYPOINT ["/app/server"] CMD ["-config", "/app/config.yaml"] diff --git a/services/user-service/configs/config.docker.yaml b/services/user-service/configs/config.docker.yaml index 1583ee7f..b23b316a 100644 --- a/services/user-service/configs/config.docker.yaml +++ b/services/user-service/configs/config.docker.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13005" +health_http_addr: ":13105" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" mysql_auto_migrate: false id_generator: diff --git a/services/user-service/configs/config.tencent.example.yaml b/services/user-service/configs/config.tencent.example.yaml index 77335d6e..47d88b96 100644 --- a/services/user-service/configs/config.tencent.example.yaml +++ b/services/user-service/configs/config.tencent.example.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13005" +health_http_addr: ":13105" mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC&tls=false" mysql_auto_migrate: false id_generator: diff --git a/services/user-service/configs/config.yaml b/services/user-service/configs/config.yaml index b4fbaf96..56ef7269 100644 --- a/services/user-service/configs/config.yaml +++ b/services/user-service/configs/config.yaml @@ -8,6 +8,7 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13005" +health_http_addr: ":13105" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC" mysql_auto_migrate: false id_generator: diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index b39f8553..5c51724b 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -11,6 +11,8 @@ import ( userv1 "hyapp.local/api/proto/user/v1" "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" "hyapp/pkg/idgen" "hyapp/pkg/logx" "hyapp/services/user-service/internal/config" @@ -36,6 +38,8 @@ type App struct { listener net.Listener // health 是标准 gRPC health 的 serving/draining 状态来源。 health *grpchealth.ServingChecker + // healthHTTP 给 CLB 和发布脚本提供 HTTP readiness,不承载业务请求。 + healthHTTP *healthhttp.Server // mysqlRepo 持有用户主数据、认证身份、session 和短号事务的唯一运行时存储。 mysqlRepo *mysqlstorage.Repository // walletDB 只用于读取 wallet_outbox 充值事实,实际账务状态仍由 wallet-service owner。 @@ -226,15 +230,47 @@ func New(cfg config.Config) (*App, error) { userv1.RegisterUserHostAdminServiceServer(server, userServer) userv1.RegisterUserCronServiceServer(server, userServer) - health := grpchealth.NewServingChecker("user-service") + healthDependencies := []grpchealth.Dependency{{ + Name: "mysql", + Check: mysqlRepo.Ping, + }} + if walletDB != nil { + healthDependencies = append(healthDependencies, grpchealth.Dependency{ + Name: "wallet_mysql", + Check: walletDB.PingContext, + }) + } + if roomDB != nil { + healthDependencies = append(healthDependencies, grpchealth.Dependency{ + Name: "room_mysql", + Check: roomDB.PingContext, + }) + } + health := grpchealth.NewServingChecker("user-service", healthDependencies...) // user-service 目前使用简单 serving checker,存储探测由后续专用 health 可扩展。 healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + if err != nil { + if roomDB != nil { + _ = roomDB.Close() + } + if walletDB != nil { + _ = walletDB.Close() + } + if loginRiskRedisClose != nil { + _ = loginRiskRedisClose() + } + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, err + } workerCtx, workerCancel := context.WithCancel(context.Background()) return &App{ server: server, listener: listener, health: health, + healthHTTP: healthHTTP, mysqlRepo: mysqlRepo, walletDB: walletDB, roomDB: roomDB, @@ -251,6 +287,7 @@ func New(cfg config.Config) (*App, error) { // Run 启动 gRPC 服务。 func (a *App) Run() error { + a.runHealthHTTP() // 只有 listener 进入 Serve 后才标记 serving,避免健康检查提前放行。 a.health.MarkServing() defer func() { @@ -297,7 +334,8 @@ func (a *App) Close() { } a.workerWG.Wait() // GracefulStop 等待已进入的 RPC 结束,避免 token/session 写入被硬切。 - a.server.GracefulStop() + grpcshutdown.GracefulStop(a.server, 15*time.Second) + a.closeHealthHTTP() if a.mysqlRepo != nil { // MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。 _ = a.mysqlRepo.Close() @@ -313,3 +351,23 @@ func (a *App) Close() { } }) } + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/user-service/internal/config/config.go b/services/user-service/internal/config/config.go index cdbffb8a..b2a8bab0 100644 --- a/services/user-service/internal/config/config.go +++ b/services/user-service/internal/config/config.go @@ -18,6 +18,8 @@ type Config struct { Environment string `yaml:"environment"` // GRPCAddr 是 user-service 内部 gRPC 监听地址。 GRPCAddr string `yaml:"grpc_addr"` + // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 + HealthHTTPAddr string `yaml:"health_http_addr"` // MySQLDSN 是用户主数据、认证身份、session 和短号事务的必需存储连接串。 MySQLDSN string `yaml:"mysql_dsn"` // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 @@ -132,6 +134,7 @@ func Default() Config { NodeID: "user-local", Environment: "local", GRPCAddr: ":13005", + HealthHTTPAddr: ":13105", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC", MySQLAutoMigrate: false, IDGenerator: IDGeneratorConfig{ @@ -209,6 +212,10 @@ func Load(path string) (Config, error) { if cfg.Environment == "" { cfg.Environment = "local" } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13105" + } cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr) if cfg.LoginRisk.BlockedTTLSec <= 0 { cfg.LoginRisk.BlockedTTLSec = 2592000 diff --git a/services/user-service/internal/service/user/service_test.go b/services/user-service/internal/service/user/service_test.go deleted file mode 100644 index 9b793bb8..00000000 --- a/services/user-service/internal/service/user/service_test.go +++ /dev/null @@ -1,1119 +0,0 @@ -// Package user_test 验证 user-service 用户主数据、短号和靓号用例。 -package user_test - -import ( - "context" - "strings" - "testing" - "time" - - "hyapp/pkg/xerr" - userdomain "hyapp/services/user-service/internal/domain/user" - userservice "hyapp/services/user-service/internal/service/user" - "hyapp/services/user-service/internal/testutil/mysqltest" -) - -type sequenceIDGenerator struct { - // values 是测试预设 user_id 序列。 - values []int64 - // index 指向当前返回值。 - index int -} - -func (g *sequenceIDGenerator) NewInt64() int64 { - // 到达末尾后保持最后一个值,便于测试重试行为。 - value := g.values[g.index] - if g.index < len(g.values)-1 { - g.index++ - } - - return value -} - -type sequenceDisplayUserIDAllocator struct { - // values 是测试预设短号候选。 - values []string -} - -func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int) string { - if attempt >= len(a.values) { - // 超出候选数量后复用最后一个值。 - return a.values[len(a.values)-1] - } - - return a.values[attempt] -} - -//go:fix inline -func strptr(value string) *string { - return new(value) -} - -func containsCountryCode(countries []userdomain.Country, code string) bool { - for _, country := range countries { - if country.CountryCode == code { - return true - } - } - - return false -} - -func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region { - t.Helper() - region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country) - if err != nil { - t.Fatalf("resolve region for country %s failed: %v", country, err) - } - if !ok { - t.Fatalf("country %s should have seeded region mapping", country) - } - - return region -} - -func completedUser(user userdomain.User) userdomain.User { - // 普通资料编辑和国家修改测试默认模拟已完成注册资料的用户;未完成场景用专门用例覆盖。 - user.ProfileCompleted = true - if user.ProfileCompletedAtMs == 0 { - user.ProfileCompletedAtMs = 1 - } - user.OnboardingStatus = userdomain.OnboardingStatusCompleted - return user -} - -func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service { - base := []userservice.Option{ - userservice.WithIdentityRepository(repository), - userservice.WithCountryRegionRepository(repository), - userservice.WithCountryAdminRepository(repository), - userservice.WithRegionAdminRepository(repository), - userservice.WithRegionRebuildRepository(repository), - userservice.WithDeviceRepository(repository), - } - return userservice.New(repository, append(base, options...)...) -} - -func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country { - t.Helper() - if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil { - t.Fatalf("resolve country %s failed: %v", code, err) - } else if ok { - return country - } - return repository.PutCountry(userdomain.Country{ - CountryName: code + " Name", - CountryCode: code, - CountryDisplayName: code + " Display", - Enabled: true, - }) -} - -func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region { - t.Helper() - return repository.PutRegion(userdomain.Region{ - RegionCode: code, - Name: code + " Region", - Status: userdomain.RegionStatusActive, - Countries: countries, - CreatedByUserID: 1, - UpdatedByUserID: 1, - }) -} - -// TestGetUserUsesRepository 验证 user-service 查询用例只依赖 repository 接口。 -func TestGetUserUsesRepository(t *testing.T) { - repository := mysqltest.NewRepository(t) - // PutUser 是测试辅助入口,直接准备用户主记录。 - repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})) - - svc := newUserService(repository) - user, err := svc.GetUser(context.Background(), 10001) - if err != nil { - t.Fatalf("GetUser failed: %v", err) - } - - if user.UserID != 10001 || user.Status != userdomain.StatusActive { - t.Fatalf("unexpected user: %+v", user) - } -} - -func TestBusinessUserLookupRestrictsSceneKeywordAndInactiveUsers(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.PutUser(completedUser(userdomain.User{ - UserID: 700001, - CurrentDisplayUserID: "910001", - Username: "Alice Star", - Avatar: "https://cdn.example/alice.png", - RegionID: 1001, - Status: userdomain.StatusActive, - })) - repository.PutUser(completedUser(userdomain.User{ - UserID: 700002, - CurrentDisplayUserID: "910002", - Username: "Alice Disabled", - RegionID: 1001, - Status: userdomain.StatusDisabled, - })) - svc := newUserService(repository) - - byDisplayID, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "910001", 99) - if err != nil { - t.Fatalf("BusinessUserLookup by display id failed: %v", err) - } - if len(byDisplayID) != 1 || byDisplayID[0].UserID != 700001 || byDisplayID[0].DisplayUserID != "910001" || byDisplayID[0].Status != userdomain.StatusActive { - t.Fatalf("display id lookup mismatch: %+v", byDisplayID) - } - - byNickname, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "Alice", 20) - if err != nil { - t.Fatalf("BusinessUserLookup by nickname failed: %v", err) - } - if len(byNickname) != 1 || byNickname[0].UserID != 700001 { - t.Fatalf("nickname lookup must exclude inactive users: %+v", byNickname) - } - - if _, err := svc.BusinessUserLookup(context.Background(), "unknown_scene", "Alice", 20); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected INVALID_ARGUMENT for unknown scene, got %v", err) - } - if _, err := svc.BusinessUserLookup(context.Background(), userservice.BusinessSceneManagerResourceGrant, "A", 20); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected INVALID_ARGUMENT for short keyword, got %v", err) - } -} - -func TestGetMyProfileStatsReadsCounterTable(t *testing.T) { - repository := mysqltest.NewRepository(t) - svc := newUserService(repository) - - stats, err := svc.GetMyProfileStats(context.Background(), 10001) - if err != nil { - t.Fatalf("GetMyProfileStats default failed: %v", err) - } - if stats.UserID != 10001 || stats.VisitorsCount != 0 || stats.FollowingCount != 0 || stats.FriendsCount != 0 { - t.Fatalf("missing stats should return zero projection: %+v", stats) - } - - _, err = repository.RawDB().Exec(` - INSERT INTO user_profile_stats ( - app_code, user_id, visitors_count, following_count, friends_count, updated_at_ms - ) VALUES ('lalu', 10001, 12, 34, 56, 7000)`) - if err != nil { - t.Fatalf("seed profile stats failed: %v", err) - } - - stats, err = svc.GetMyProfileStats(context.Background(), 10001) - if err != nil { - t.Fatalf("GetMyProfileStats failed: %v", err) - } - if stats.VisitorsCount != 12 || stats.FollowingCount != 34 || stats.FriendsCount != 56 || stats.UpdatedAtMs != 7000 { - t.Fatalf("stats mismatch: %+v", stats) - } -} - -func TestSocialRelationsUseReadModelsAndIdempotentEdges(t *testing.T) { - repository := mysqltest.NewRepository(t) - for _, seeded := range []userdomain.User{ - {UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}, - {UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive}, - {UserID: 10003, CurrentDisplayUserID: "100003", Status: userdomain.StatusActive}, - } { - repository.PutUser(completedUser(seeded)) - } - now := time.UnixMilli(9000) - svc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) - - if _, _, err := svc.RecordProfileVisit(context.Background(), 10001, 10001); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("self visit should be rejected, got %v", err) - } - recorded, stats, err := svc.RecordProfileVisit(context.Background(), 10001, 10002) - if err != nil || !recorded || stats.VisitorsCount != 1 { - t.Fatalf("first visit mismatch: recorded=%v stats=%+v err=%v", recorded, stats, err) - } - recorded, stats, err = svc.RecordProfileVisit(context.Background(), 10001, 10002) - if err != nil || recorded || stats.VisitorsCount != 1 { - t.Fatalf("repeat visit should not increase unique visitors: recorded=%v stats=%+v err=%v", recorded, stats, err) - } - visitors, total, err := svc.ListProfileVisitors(context.Background(), 10002, 1, 20) - if err != nil || total != 1 || len(visitors) != 1 || visitors[0].VisitCount != 2 || visitors[0].VisitorUserID != 10001 { - t.Fatalf("visitors mismatch: visitors=%+v total=%d err=%v", visitors, total, err) - } - - now = time.UnixMilli(8000) - stats, err = svc.FollowUser(context.Background(), 10001, 10002) - if err != nil || stats.FollowingCount != 1 { - t.Fatalf("follow failed: stats=%+v err=%v", stats, err) - } - stats, err = svc.FollowUser(context.Background(), 10001, 10002) - if err != nil || stats.FollowingCount != 1 { - t.Fatalf("repeat follow should be idempotent: stats=%+v err=%v", stats, err) - } - now = time.UnixMilli(9000) - stats, err = svc.FollowUser(context.Background(), 10001, 10003) - if err != nil || stats.FollowingCount != 2 { - t.Fatalf("second follow failed: stats=%+v err=%v", stats, err) - } - following, total, err := svc.ListFollowing(context.Background(), 10001, 1, 20, 0, 0) - if err != nil || total != 2 || len(following) != 2 || following[0].FolloweeUserID != 10003 || following[1].FolloweeUserID != 10002 { - t.Fatalf("following mismatch: following=%+v total=%d err=%v", following, total, err) - } - following, total, err = svc.ListFollowing(context.Background(), 10001, 1, 20, 9000, 10003) - if err != nil || total != 2 || len(following) != 1 || following[0].FolloweeUserID != 10002 { - t.Fatalf("following cursor mismatch: following=%+v total=%d err=%v", following, total, err) - } - stats, err = svc.UnfollowUser(context.Background(), 10001, 10002) - if err != nil || stats.FollowingCount != 1 { - t.Fatalf("unfollow failed: stats=%+v err=%v", stats, err) - } - stats, err = svc.UnfollowUser(context.Background(), 10001, 10002) - if err != nil || stats.FollowingCount != 1 { - t.Fatalf("repeat unfollow should stay unchanged: stats=%+v err=%v", stats, err) - } - - application, alreadyFriends, err := svc.ApplyFriend(context.Background(), 10001, 10002) - if err != nil || alreadyFriends || application.Status != userdomain.FriendApplicationStatusPending { - t.Fatalf("apply friend mismatch: app=%+v already=%v err=%v", application, alreadyFriends, err) - } - friends, total, err := svc.ListFriends(context.Background(), 10001, 1, 20, 0, 0) - if err != nil || total != 0 || len(friends) != 0 { - t.Fatalf("friend application must not create friendship before accept: friends=%+v total=%d err=%v", friends, total, err) - } - applications, total, err := svc.ListFriendApplications(context.Background(), 10002, "incoming", 1, 20) - if err != nil || total != 1 || len(applications) != 1 || applications[0].RequesterUserID != 10001 { - t.Fatalf("incoming application mismatch: applications=%+v total=%d err=%v", applications, total, err) - } - friend, err := svc.AcceptFriendApplication(context.Background(), 10002, 10001) - if err != nil || friend.UserID != 10002 || friend.FriendUserID != 10001 { - t.Fatalf("accept friend failed: friend=%+v err=%v", friend, err) - } - friend, err = svc.AcceptFriendApplication(context.Background(), 10002, 10001) - if err != nil || friend.UserID != 10002 || friend.FriendUserID != 10001 { - t.Fatalf("repeat accept should be idempotent: friend=%+v err=%v", friend, err) - } - for _, userID := range []int64{10001, 10002} { - friends, total, err = svc.ListFriends(context.Background(), userID, 1, 20, 0, 0) - if err != nil || total != 1 || len(friends) != 1 { - t.Fatalf("bidirectional friendship missing for user %d: friends=%+v total=%d err=%v", userID, friends, total, err) - } - stats, err = svc.GetMyProfileStats(context.Background(), userID) - if err != nil || stats.FriendsCount != 1 { - t.Fatalf("friend count mismatch for user %d: stats=%+v err=%v", userID, stats, err) - } - } - deleted, err := svc.DeleteFriend(context.Background(), 10001, 10002) - if err != nil || !deleted { - t.Fatalf("delete friend failed: deleted=%v err=%v", deleted, err) - } - deleted, err = svc.DeleteFriend(context.Background(), 10001, 10002) - if err != nil || deleted { - t.Fatalf("repeat delete should be idempotent false: deleted=%v err=%v", deleted, err) - } - for _, userID := range []int64{10001, 10002} { - friends, total, err = svc.ListFriends(context.Background(), userID, 1, 20, 0, 0) - if err != nil || total != 0 || len(friends) != 0 { - t.Fatalf("friend should be removed for user %d: friends=%+v total=%d err=%v", userID, friends, total, err) - } - stats, err = svc.GetMyProfileStats(context.Background(), userID) - if err != nil || stats.FriendsCount != 0 { - t.Fatalf("friend count should be decremented for user %d: stats=%+v err=%v", userID, stats, err) - } - } -} - -func TestListUserIDsUsesCursorAndTargetFilters(t *testing.T) { - repository := mysqltest.NewRepository(t) - for _, user := range []userdomain.User{ - {UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive, Country: "US", RegionID: 7}, - {UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive, Country: "SG", RegionID: 8}, - {UserID: 10003, CurrentDisplayUserID: "100003", Status: userdomain.StatusActive, Country: "US", RegionID: 7}, - {UserID: 10004, CurrentDisplayUserID: "100004", Status: userdomain.StatusDisabled, Country: "US", RegionID: 7}, - } { - repository.PutUser(completedUser(user)) - } - svc := newUserService(repository) - - userIDs, next, done, err := svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetAllActiveUsers, PageSize: 2}) - if err != nil || done || next != 10002 || len(userIDs) != 2 || userIDs[0] != 10001 || userIDs[1] != 10002 { - t.Fatalf("all active first page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err) - } - userIDs, next, done, err = svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetRegion, RegionID: 7, CursorUserID: next, PageSize: 10}) - if err != nil || !done || next != 10003 || len(userIDs) != 1 || userIDs[0] != 10003 { - t.Fatalf("region page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err) - } - userIDs, next, done, err = svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetCountry, Country: " us ", PageSize: 10}) - if err != nil || !done || next != 10003 || len(userIDs) != 2 || userIDs[0] != 10001 || userIDs[1] != 10003 { - t.Fatalf("country page mismatch: ids=%v next=%d done=%v err=%v", userIDs, next, done, err) - } - if _, _, _, err := svc.ListUserIDs(context.Background(), userdomain.UserIDPageFilter{TargetScope: userdomain.UserIDTargetCountry, Country: "1"}); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("invalid country should fail, got %v", err) - } -} - -func TestUpdateUserProfile(t *testing.T) { - repository := mysqltest.NewRepository(t) - repository.PutUser(completedUser(userdomain.User{ - UserID: 10001, - CurrentDisplayUserID: "100001", - Username: "old-name", - Avatar: "old-avatar", - BirthDate: "1999-01-01", - Status: userdomain.StatusActive, - })) - now := time.UnixMilli(2000) - svc := newUserService(repository, userservice.WithClock(func() time.Time { return now })) - - user, err := svc.UpdateUserProfile(context.Background(), 10001, new(" new-name "), new(" https://cdn.example/a.png "), new(" male "), new("2000-01-02")) - if err != nil { - t.Fatalf("UpdateUserProfile failed: %v", err) - } - if user.Username != "new-name" || user.Avatar != "https://cdn.example/a.png" || user.Gender != "male" || user.BirthDate != "2000-01-02" || user.UpdatedAtMs != 2000 { - t.Fatalf("profile update mismatch: %+v", user) - } - - _, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, new("2000/01/02")) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected invalid birth format, got %v", err) - } - _, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, nil, new("0001-01-01")) - if !xerr.IsCode(err, xerr.InvalidArgument) { - // Go 零值日期虽然能被 time.Parse 接受,但 MySQL DATE 严格模式不能写入,必须在 service 层拒绝。 - t.Fatalf("expected invalid birth range, got %v", err) - } - - invalidCases := []struct { - name string - username *string - avatar *string - gender *string - birth *string - }{ - { - name: "empty username", - username: new(" "), - }, - { - name: "too long username", - username: new(strings.Repeat("a", userdomain.ProfileUsernameMaxRunes+1)), - }, - { - name: "too long avatar", - avatar: new("https://cdn.example/" + strings.Repeat("a", userdomain.ProfileAvatarMaxRunes)), - }, - { - name: "non http avatar", - avatar: new("ftp://cdn.example/a.png"), - }, - { - name: "empty gender", - gender: new(" "), - }, - } - for _, tc := range invalidCases { - t.Run(tc.name, func(t *testing.T) { - _, err := svc.UpdateUserProfile(context.Background(), 10001, tc.username, tc.avatar, tc.gender, tc.birth) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected invalid profile update, got %v", err) - } - }) - } - - user, err = svc.UpdateUserProfile(context.Background(), 10001, nil, new(""), nil, new("")) - if err != nil { - t.Fatalf("clearing optional profile fields failed: %v", err) - } - if user.Avatar != "" || user.BirthDate != "" { - t.Fatalf("optional profile fields should clear: %+v", user) - } -} - -func TestBindAndDeletePushToken(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - current := time.UnixMilli(1700000000000) - svc := newUserService(repository, userservice.WithClock(func() time.Time { return current })) - repository.PutUser(completedUser(userdomain.User{ - UserID: 10001, - DefaultDisplayUserID: "100001", - CurrentDisplayUserID: "100001", - Status: userdomain.StatusActive, - })) - - updatedAt, err := svc.BindPushToken(ctx, 10001, " dev-1 ", " push-token-1 ", "", " android ", "1.2.3", "en-US", "America/Los_Angeles", "req-push-bind") - if err != nil { - t.Fatalf("BindPushToken failed: %v", err) - } - if updatedAt != 1700000000000 { - t.Fatalf("updated_at mismatch: got %d", updatedAt) - } - - deleted, deletedAt, err := svc.DeletePushToken(ctx, 10001, "dev-1", "push-token-1", "req-push-delete") - if err != nil { - t.Fatalf("DeletePushToken failed: %v", err) - } - if !deleted || deletedAt != 1700000000000 { - t.Fatalf("delete response mismatch: deleted=%v deletedAt=%d", deleted, deletedAt) - } -} - -func TestBindPushTokenValidation(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newUserService(repository) - repository.PutUser(completedUser(userdomain.User{ - UserID: 10001, - DefaultDisplayUserID: "100001", - CurrentDisplayUserID: "100001", - Status: userdomain.StatusActive, - })) - - tests := []struct { - name string - deviceID string - token string - platform string - wantCode xerr.Code - }{ - {name: "device", deviceID: "", token: "push-1", platform: "android", wantCode: xerr.InvalidArgument}, - {name: "token", deviceID: "dev-1", token: "", platform: "android", wantCode: xerr.InvalidArgument}, - {name: "platform", deviceID: "dev-1", token: "push-1", platform: "web", wantCode: xerr.InvalidArgument}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := svc.BindPushToken(ctx, 10001, test.deviceID, test.token, "", test.platform, "", "", "", "req-"+test.name) - if !xerr.IsCode(err, test.wantCode) { - t.Fatalf("error code mismatch: got %v want %s", err, test.wantCode) - } - }) - } -} - -func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing.T) { - // CompleteOnboarding 是注册页原子提交点,首次国家写入不能污染后续国家修改冷却日志。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "SG") - seedCountry(t, repository, "US") - region := mustResolveRegionByCountry(t, repository, "SG") - repository.PutUser(userdomain.User{ - UserID: 10001, - CurrentDisplayUserID: "100001", - Status: userdomain.StatusActive, - }) - current := time.UnixMilli(1000) - svc := newUserService(repository, - userservice.WithClock(func() time.Time { return current }), - userservice.WithCountryChangeCooldown(time.Hour), - ) - - user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " male ", " sg ", "", "") - if err != nil { - t.Fatalf("CompleteOnboarding failed: %v", err) - } - if user.Username != "hy" || user.Avatar != "https://cdn.example/a.png" || user.Gender != "male" || user.Country != "SG" || user.RegionID != region.RegionID { - t.Fatalf("onboarding profile mismatch: %+v", user) - } - if !user.ProfileCompleted || user.ProfileCompletedAtMs != 1000 || user.OnboardingStatus != userdomain.OnboardingStatusCompleted { - t.Fatalf("onboarding completion flags mismatch: %+v", user) - } - - current = current.Add(10 * time.Minute) - user, _, err = svc.ChangeUserCountry(ctx, 10001, "US", "req-change-after-onboarding") - if err != nil { - t.Fatalf("first country change after onboarding must not be cooled down: %v", err) - } - if user.Country != "US" { - t.Fatalf("country after first change mismatch: %+v", user) - } -} - -func TestCountryWithoutExplicitRegionFallsBackToGlobal(t *testing.T) { - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "ZZ") - repository.PutUser(userdomain.User{ - UserID: 10001, - CurrentDisplayUserID: "100001", - Status: userdomain.StatusActive, - }) - svc := newUserService(repository) - - region, ok, err := repository.ResolveActiveRegionByCountry(ctx, "ZZ") - if err != nil { - t.Fatalf("ResolveActiveRegionByCountry failed: %v", err) - } - if !ok || region.RegionID != 0 || region.RegionCode != userdomain.GlobalRegionCode { - t.Fatalf("country without mapping should resolve to GLOBAL: ok=%t region=%+v", ok, region) - } - - user, err := svc.CompleteOnboarding(ctx, 10001, "hy", "https://cdn.example/a.png", "male", "ZZ", "", "") - if err != nil { - t.Fatalf("CompleteOnboarding failed: %v", err) - } - if user.RegionID != 0 || user.RegionCode != userdomain.GlobalRegionCode || user.RegionName != userdomain.GlobalRegionCode { - t.Fatalf("user should enter GLOBAL region: %+v", user) - } -} - -func TestCompleteOnboardingValidation(t *testing.T) { - // 必填字段和展示资料格式在 service 层稳定返回 INVALID_ARGUMENT,不能依赖 MySQL 约束报错。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "SG") - repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})) - svc := newUserService(repository) - - cases := []struct { - name string - username string - avatar string - gender string - country string - }{ - {name: "username", username: " ", avatar: "https://cdn.example/a.png", gender: "male", country: "SG"}, - {name: "avatar", username: "hy", avatar: "", gender: "male", country: "SG"}, - {name: "avatar_url", username: "hy", avatar: "ftp://cdn.example/a.png", gender: "male", country: "SG"}, - {name: "gender", username: "hy", avatar: "https://cdn.example/a.png", gender: "", country: "SG"}, - {name: "gender_too_long", username: "hy", avatar: "https://cdn.example/a.png", gender: strings.Repeat("a", userdomain.ProfileGenderMaxRunes+1), country: "SG"}, - {name: "country", username: "hy", avatar: "https://cdn.example/a.png", gender: "male", country: ""}, - {name: "unknown_country", username: "hy", avatar: "https://cdn.example/a.png", gender: "male", country: "ZZ"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.gender, tc.country, "", "") - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected INVALID_ARGUMENT, got %v", err) - } - }) - } -} - -func TestChangeUserCountryWritesCooldown(t *testing.T) { - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "CN") - seedCountry(t, repository, "US") - seedCountry(t, repository, "JP") - repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})) - current := time.UnixMilli(1000) - svc := newUserService(repository, - userservice.WithClock(func() time.Time { return current }), - userservice.WithCountryChangeCooldown(time.Hour), - ) - - user, nextAllowedAt, err := svc.ChangeUserCountry(context.Background(), 10001, " US ", "req-country-1") - if err != nil { - t.Fatalf("ChangeUserCountry failed: %v", err) - } - if user.Country != "US" || nextAllowedAt != current.Add(time.Hour).UnixMilli() { - t.Fatalf("country change mismatch: user=%+v next=%d", user, nextAllowedAt) - } - - current = current.Add(10 * time.Minute) - if _, _, err := svc.ChangeUserCountry(context.Background(), 10001, "JP", "req-country-2"); !xerr.IsCode(err, xerr.CountryChangeCooldown) { - t.Fatalf("expected country cooldown, got %v", err) - } - if user, _, err := svc.ChangeUserCountry(context.Background(), 10001, "US", "req-country-same"); err != nil || user.Country != "US" { - // 相同国家重复提交不写新日志,也不被冷却期阻断。 - t.Fatalf("same country should be idempotent: user=%+v err=%v", user, err) - } - - current = current.Add(2 * time.Hour) - user, _, err = svc.ChangeUserCountry(context.Background(), 10001, "JP", "req-country-3") - if err != nil { - t.Fatalf("ChangeUserCountry after cooldown failed: %v", err) - } - if user.Country != "JP" { - t.Fatalf("country after cooldown mismatch: %+v", user) - } -} - -func TestProfileMutationsRequireCompletedOnboarding(t *testing.T) { - // 未完成注册资料时,CompleteOnboarding 是唯一资料写入口;改国家不能提前写日志或消耗冷却。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "CN") - seedCountry(t, repository, "US") - repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive}) - current := time.UnixMilli(1000) - svc := newUserService(repository, - userservice.WithClock(func() time.Time { return current }), - userservice.WithCountryChangeCooldown(time.Hour), - ) - - if _, err := svc.UpdateUserProfile(ctx, 10001, new("new-name"), nil, nil, nil); !xerr.IsCode(err, xerr.ProfileRequired) { - t.Fatalf("expected PROFILE_REQUIRED for incomplete profile update, got %v", err) - } - if _, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-before-onboarding"); !xerr.IsCode(err, xerr.ProfileRequired) { - t.Fatalf("expected PROFILE_REQUIRED for incomplete country change, got %v", err) - } - - completed := completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive}) - repository.PutUser(completed) - user, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-after-onboarding") - if err != nil { - // 如果未完成阶段误写了国家变更日志,这里会被冷却期挡住。 - t.Fatalf("first country change after completing profile should not be cooled down: %v", err) - } - if user.Country != "US" { - t.Fatalf("country after completed profile mismatch: %+v", user) - } -} - -func TestCountryAndRegionAdminValidation(t *testing.T) { - // user-service 仍负责国家更新归一、元数据校验和区域国家归属校验。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newUserService(repository) - - aa := repository.PutCountry(userdomain.Country{ - CountryName: "Alpha Area", - CountryCode: "AA", - CountryDisplayName: "测试国家", - Enabled: true, - SortOrder: 10, - }) - aa, err := svc.UpdateCountry(ctx, userdomain.UpdateCountryCommand{ - CountryID: aa.CountryID, - CountryName: "Alpha Area", - ISOAlpha3: " aaa ", - ISONumeric: " 001 ", - CountryDisplayName: "测试国家", - PhoneCountryCode: " +1 ", - Flag: "AA", - SortOrder: 10, - OperatorUserID: 1, - RequestID: "req-update-aa", - }) - if err != nil { - t.Fatalf("UpdateCountry AA failed: %v", err) - } - if aa.CountryCode != "AA" || aa.ISOAlpha3 != "AAA" || aa.ISONumeric != "001" || aa.PhoneCountryCode != "+1" || !aa.Enabled { - t.Fatalf("country code should normalize to AA: %+v", aa) - } - tla := repository.PutCountry(userdomain.Country{ - CountryName: "Three Letter Land", - CountryCode: "tla", - CountryDisplayName: "三字码国家", - Enabled: true, - SortOrder: 30, - }) - if tla.CountryCode != "TLA" { - t.Fatalf("three-letter canonical country code mismatch: %+v", tla) - } - if _, err := svc.UpdateCountry(ctx, userdomain.UpdateCountryCommand{CountryID: aa.CountryID, CountryName: "Bad ISO", ISOAlpha3: "B1D", CountryDisplayName: "Bad ISO", OperatorUserID: 1, RequestID: "req-bad-iso"}); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected invalid iso alpha3, got %v", err) - } - - sea := repository.PutRegion(userdomain.Region{ - RegionCode: "sea", - Name: "Southeast Asia", - Status: userdomain.RegionStatusActive, - Countries: []string{"AA"}, - }) - sea, err = svc.UpdateRegion(ctx, sea.RegionID, "sea", "Southeast Asia", 0, 1, "req-sea") - if err != nil { - t.Fatalf("UpdateRegion failed: %v", err) - } - if sea.RegionCode != "SEA" || len(sea.Countries) != 1 || sea.Countries[0] != "AA" { - t.Fatalf("region normalization mismatch: %+v", sea) - } - subregion := repository.PutRegion(userdomain.Region{ - RegionCode: "TEST_SUBREGION", - Name: "测试子区域", - Status: userdomain.RegionStatusActive, - Countries: []string{"TLA"}, - }) - subregion, err = svc.UpdateRegion(ctx, subregion.RegionID, "Test Subregion", "测试子区域", 0, 1, "req-subregion") - if err != nil { - t.Fatalf("descriptive subregion code should be accepted: %v", err) - } - if subregion.RegionCode != "Test Subregion" || len(subregion.Countries) != 1 || subregion.Countries[0] != "TLA" { - t.Fatalf("descriptive region code mismatch: %+v", subregion) - } - if _, err := svc.ReplaceRegionCountries(ctx, subregion.RegionID, []string{"AA"}, 1, "req-apac"); !xerr.IsCode(err, xerr.RegionCountryConflict) { - t.Fatalf("expected region country conflict, got %v", err) - } -} - -func TestRegistrationCountriesUseEnabledFlagAndRegionKeepsDisabledCountryMapping(t *testing.T) { - // enabled 只控制 App 用户能否选择;区域预配置仍允许引用暂未开放的国家。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - svc := newUserService(repository, userservice.WithCountryChangeCooldown(0)) - - openCountry := repository.PutCountry(userdomain.Country{ - CountryName: "Beta Bay", - CountryCode: "BB", - ISONumeric: "997", - CountryDisplayName: "Beta", - PhoneCountryCode: "+997", - Enabled: true, - SortOrder: -50, - }) - closedCountry := repository.PutCountry(userdomain.Country{ - CountryName: "Closed Country", - CountryCode: "CC", - ISONumeric: "998", - CountryDisplayName: "Closed", - PhoneCountryCode: "+998", - Enabled: false, - SortOrder: -100, - }) - - countries, err := svc.ListRegistrationCountries(ctx) - if err != nil { - t.Fatalf("ListRegistrationCountries failed: %v", err) - } - if !containsCountryCode(countries, openCountry.CountryCode) || containsCountryCode(countries, closedCountry.CountryCode) { - t.Fatalf("registration list must include enabled and exclude disabled countries: %+v", countries) - } - region := repository.PutRegion(userdomain.Region{ - RegionCode: "pre", - Name: "Preconfigured", - Status: userdomain.RegionStatusActive, - Countries: []string{closedCountry.CountryCode}, - CreatedByUserID: 1, - UpdatedByUserID: 1, - }) - if len(region.Countries) != 1 || region.Countries[0] != closedCountry.CountryCode { - t.Fatalf("region countries mismatch: %+v", region) - } - - repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})) - if _, _, err := svc.ChangeUserCountry(ctx, 10001, closedCountry.CountryCode, "req-change-closed"); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("disabled country must be rejected for user selection, got %v", err) - } - - repository.SetCountryEnabled(closedCountry.CountryID, true) - countries, err = svc.ListRegistrationCountries(ctx) - if err != nil { - t.Fatalf("ListRegistrationCountries after enable failed: %v", err) - } - if len(countries) == 0 || countries[0].CountryCode != closedCountry.CountryCode || !containsCountryCode(countries, openCountry.CountryCode) { - t.Fatalf("registration list should sort enabled countries by sort_order: %+v", countries) - } - - repository.SetCountryEnabled(closedCountry.CountryID, false) - region, err = svc.GetRegion(ctx, region.RegionID) - if err != nil { - t.Fatalf("GetRegion failed: %v", err) - } - if len(region.Countries) != 1 || region.Countries[0] != closedCountry.CountryCode { - t.Fatalf("disabling country must not release region mapping: %+v", region) - } -} - -func TestChangeUserCountryUpdatesRegionAndSameCountryRepairsStaleRegion(t *testing.T) { - // 改国家同步重算 region_id;相同国家不写冷却日志,但允许修正历史 stale region_id。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "US") - seedCountry(t, repository, "SG") - region := mustResolveRegionByCountry(t, repository, "SG") - repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "US", Status: userdomain.StatusActive})) - current := time.UnixMilli(1000) - svc := newUserService(repository, - userservice.WithClock(func() time.Time { return current }), - userservice.WithCountryChangeCooldown(time.Hour), - ) - - user, _, err := svc.ChangeUserCountry(ctx, 10001, "sg", "req-sg") - if err != nil { - t.Fatalf("ChangeUserCountry SG failed: %v", err) - } - if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode { - t.Fatalf("region after country change mismatch: %+v", user) - } - - stale := user - stale.RegionID = 0 - repository.PutUser(stale) - current = current.Add(10 * time.Minute) - user, _, err = svc.ChangeUserCountry(ctx, 10001, "SG", "req-same") - if err != nil { - t.Fatalf("same-country stale repair should not hit cooldown: %v", err) - } - if user.RegionID != region.RegionID { - t.Fatalf("same-country stale region was not repaired: %+v", user) - } - - current = current.Add(10 * time.Minute) - if _, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-cooldown"); !xerr.IsCode(err, xerr.CountryChangeCooldown) { - t.Fatalf("expected cooldown after real country change, got %v", err) - } -} - -func TestChangeUserCountryRejectsUnknownOrDisabledCountry(t *testing.T) { - // 改国家不能把未配置或 disabled 国家码写入 users.country。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - country := seedCountry(t, repository, "MY") - repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "", Status: userdomain.StatusActive})) - svc := newUserService(repository, userservice.WithCountryChangeCooldown(0)) - repository.SetCountryEnabled(country.CountryID, false) - - for _, country := range []string{"ZZ", "MY"} { - if _, _, err := svc.ChangeUserCountry(ctx, 10001, country, "req-"+country); !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected INVALID_ARGUMENT for country %s, got %v", country, err) - } - } -} - -func TestRegionRebuildWorkerUpdatesHistoricalUsers(t *testing.T) { - // 管理端新增区域后,worker 必须消费 rebuild task,把历史同 country 用户刷到目标 region_id。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "QAA") - repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "QAA", Status: userdomain.StatusActive}) - repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Country: "QAA", Status: userdomain.StatusActive}) - current := time.UnixMilli(1000) - svc := newUserService(repository, userservice.WithClock(func() time.Time { return current })) - region := repository.PutRegion(userdomain.Region{ - RegionCode: "SEA", - Name: "Southeast Asia", - Status: userdomain.RegionStatusActive, - Countries: []string{"QAA"}, - CreatedByUserID: 1, - UpdatedByUserID: 1, - CreatedAtMs: current.UnixMilli(), - UpdatedAtMs: current.UnixMilli(), - }) - - current = time.UnixMilli(2000) - result, err := svc.ProcessNextRegionRebuildTask(ctx, userservice.RegionRebuildWorkerOptions{WorkerID: "test", BatchSize: 100}) - if err != nil { - t.Fatalf("ProcessNextRegionRebuildTask failed: %v", err) - } - if result.Status != userdomain.RegionRebuildTaskStatusCompleted || result.ProcessedUsers != 2 { - t.Fatalf("unexpected rebuild result: %+v", result) - } - for _, userID := range []int64{10001, 10002} { - user, err := repository.GetUser(ctx, userID) - if err != nil { - t.Fatalf("GetUser %d failed: %v", userID, err) - } - if user.RegionID != region.RegionID || user.RegionCode != "SEA" { - t.Fatalf("user region was not rebuilt: %+v", user) - } - } -} - -func TestRegionRebuildWorkerSkipsStaleRevision(t *testing.T) { - // 同一 country 出现更新 revision 后,旧任务必须 skipped,避免旧区域覆盖新区域或清空结果。 - ctx := context.Background() - repository := mysqltest.NewRepository(t) - seedCountry(t, repository, "QAB") - current := time.UnixMilli(1000) - svc := newUserService(repository, userservice.WithClock(func() time.Time { return current })) - region := repository.PutRegion(userdomain.Region{ - RegionCode: "SEA", - Name: "Southeast Asia", - Status: userdomain.RegionStatusActive, - Countries: []string{"QAB"}, - CreatedByUserID: 1, - UpdatedByUserID: 1, - CreatedAtMs: current.UnixMilli(), - UpdatedAtMs: current.UnixMilli(), - }) - repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "QAB", RegionID: region.RegionID, Status: userdomain.StatusActive}) - - current = time.UnixMilli(2000) - if _, err := svc.ReplaceRegionCountries(ctx, region.RegionID, []string{"QAB"}, 1, "req-replace-sea"); err != nil { - t.Fatalf("ReplaceRegionCountries failed: %v", err) - } - - current = time.UnixMilli(3000) - result, err := svc.ProcessNextRegionRebuildTask(ctx, userservice.RegionRebuildWorkerOptions{WorkerID: "test", BatchSize: 100}) - if err != nil { - t.Fatalf("Process stale task failed: %v", err) - } - if result.Status != userdomain.RegionRebuildTaskStatusSkipped { - t.Fatalf("old task should be skipped: %+v", result) - } - current = time.UnixMilli(4000) - result, err = svc.ProcessNextRegionRebuildTask(ctx, userservice.RegionRebuildWorkerOptions{WorkerID: "test", BatchSize: 100}) - if err != nil { - t.Fatalf("Process latest task failed: %v", err) - } - if result.Status != userdomain.RegionRebuildTaskStatusCompleted || result.TargetRegionID != region.RegionID { - t.Fatalf("latest task should set region: %+v", result) - } - user, err := repository.GetUser(ctx, 10001) - if err != nil { - t.Fatalf("GetUser failed: %v", err) - } - if user.RegionID != region.RegionID { - t.Fatalf("stale region should be refreshed by latest task: %+v", user) - } -} - -// TestGetUserValidatesID 锁定 service 层的领域错误,不让 transport 层承担参数校验。 -func TestGetUserValidatesID(t *testing.T) { - svc := newUserService(mysqltest.NewRepository(t)) - - _, err := svc.GetUser(context.Background(), 0) - if !xerr.IsCode(err, xerr.InvalidArgument) { - t.Fatalf("expected INVALID_ARGUMENT, got %v", err) - } -} - -// TestCreateUserAllocatesDisplayUserID 验证新用户创建时同时生成 user_id 和 active display_user_id。 -func TestCreateUserAllocatesDisplayUserID(t *testing.T) { - repository := mysqltest.NewRepository(t) - svc := newUserService(repository, - userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001}}), - userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001"}}), - userservice.WithClock(func() time.Time { return time.UnixMilli(1000) }), - ) - - user, identity, err := svc.CreateUser(context.Background()) - if err != nil { - t.Fatalf("CreateUser failed: %v", err) - } - - if user.UserID != 900001 || identity.DisplayUserID != "100001" { - t.Fatalf("unexpected created identity: user=%+v identity=%+v", user, identity) - } - - resolved, err := svc.ResolveDisplayUserID(context.Background(), "100001") - if err != nil { - t.Fatalf("ResolveDisplayUserID failed: %v", err) - } - - if resolved.UserID != 900001 { - t.Fatalf("resolved user mismatch: %+v", resolved) - } -} - -// TestCreateUserRetriesDisplayUserIDConflict 验证默认短号冲突时有限重试。 -func TestCreateUserRetriesDisplayUserIDConflict(t *testing.T) { - repository := mysqltest.NewRepository(t) - // 先占用 100001,强制 CreateUser 走第二个候选。 - repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}) - svc := newUserService(repository, - userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001, 900002}}), - userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001", "100002"}}), - ) - - _, identity, err := svc.CreateUser(context.Background()) - if err != nil { - t.Fatalf("CreateUser should retry display_user_id conflict: %v", err) - } - - if identity.DisplayUserID != "100002" { - t.Fatalf("retry display_user_id mismatch: %+v", identity) - } -} - -// TestResolveDisplayUserIDNotFound 锁定短号不存在的专用 reason。 -func TestResolveDisplayUserIDNotFound(t *testing.T) { - svc := newUserService(mysqltest.NewRepository(t)) - - _, err := svc.ResolveDisplayUserID(context.Background(), "100001") - if !xerr.IsCode(err, xerr.DisplayUserIDNotFound) { - t.Fatalf("expected DISPLAY_USER_ID_NOT_FOUND, got %v", err) - } -} - -// TestChangeDisplayUserID 验证修改短号后旧短号不再解析。 -func TestChangeDisplayUserID(t *testing.T) { - repository := mysqltest.NewRepository(t) - // 当前默认短号为 100001,修改后旧短号应释放。 - repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}) - now := time.UnixMilli(2000) - svc := newUserService(repository, - userservice.WithClock(func() time.Time { return now }), - userservice.WithDisplayUserIDPolicy(8, 30*24*time.Hour), - ) - - identity, err := svc.ChangeDisplayUserID(context.Background(), 10001, "100002", "user_request", 10001, "req-1") - if err != nil { - t.Fatalf("ChangeDisplayUserID failed: %v", err) - } - - if identity.DisplayUserID != "100002" { - t.Fatalf("changed identity mismatch: %+v", identity) - } - - _, err = svc.ResolveDisplayUserID(context.Background(), "100001") - if !xerr.IsCode(err, xerr.DisplayUserIDNotFound) { - t.Fatalf("expected old display_user_id to be released, got %v", err) - } -} - -// TestChangeDisplayUserIDConflictAndCooldown 锁定占用和冷却期两个失败分支。 -func TestChangeDisplayUserIDConflictAndCooldown(t *testing.T) { - repository := mysqltest.NewRepository(t) - // 100002 被另一个用户占用,用来验证短号冲突分支。 - repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}) - repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive}) - current := time.UnixMilli(3000) - svc := newUserService(repository, - userservice.WithClock(func() time.Time { return current }), - userservice.WithDisplayUserIDPolicy(8, time.Hour), - ) - - _, err := svc.ChangeDisplayUserID(context.Background(), 10001, "100002", "conflict", 10001, "req-1") - if !xerr.IsCode(err, xerr.DisplayUserIDExists) { - t.Fatalf("expected DISPLAY_USER_ID_EXISTS, got %v", err) - } - - if _, err := svc.ChangeDisplayUserID(context.Background(), 10001, "100003", "first", 10001, "req-2"); err != nil { - t.Fatalf("first change failed: %v", err) - } - - current = current.Add(10 * time.Minute) - // 冷却期为 1 小时,10 分钟后再次修改应被拒绝。 - _, err = svc.ChangeDisplayUserID(context.Background(), 10001, "100004", "second", 10001, "req-3") - if !xerr.IsCode(err, xerr.DisplayUserIDCooldown) { - t.Fatalf("expected DISPLAY_USER_ID_COOLDOWN, got %v", err) - } -} - -// TestApplyPrettyDisplayUserIDOverridesAndExpires 锁定“临时靓号覆盖当前展示号,过期恢复默认短号”的核心语义。 -func TestApplyPrettyDisplayUserIDOverridesAndExpires(t *testing.T) { - // 覆盖靓号申请、默认号 held、靓号 active 解析、过期后默认号恢复。 - repository := mysqltest.NewRepository(t) - current := time.UnixMilli(1000) - svc := newUserService(repository, - userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001}}), - userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001"}}), - userservice.WithClock(func() time.Time { return current }), - ) - - user, _, err := svc.CreateUser(context.Background()) - if err != nil { - t.Fatalf("CreateUser failed: %v", err) - } - - identity, leaseID, err := svc.ApplyPrettyDisplayUserID(context.Background(), user.UserID, "888888", 1000, "receipt-1", "req-pretty") - if err != nil { - t.Fatalf("ApplyPrettyDisplayUserID failed: %v", err) - } - if leaseID == "" || identity.DisplayUserID != "888888" || identity.DefaultDisplayUserID != "100001" || identity.DisplayUserIDKind != userdomain.DisplayUserIDKindPretty { - t.Fatalf("unexpected pretty identity: identity=%+v lease=%s", identity, leaseID) - } - - if _, _, err := svc.ApplyPrettyDisplayUserID(context.Background(), user.UserID, "999999", 1000, "receipt-2", "req-pretty-2"); !xerr.IsCode(err, xerr.DisplayUserIDPrettyActive) { - // active 靓号期间不能再申请第二个靓号。 - t.Fatalf("expected DISPLAY_USER_ID_PRETTY_ACTIVE, got %v", err) - } - - if resolved, err := svc.ResolveDisplayUserID(context.Background(), "888888"); err != nil || resolved.UserID != user.UserID { - t.Fatalf("pretty display_user_id should resolve while active: identity=%+v err=%v", resolved, err) - } - if _, err := svc.ResolveDisplayUserID(context.Background(), "100001"); !xerr.IsCode(err, xerr.DisplayUserIDNotFound) { - t.Fatalf("default display_user_id should be held while pretty is active, got %v", err) - } - - current = time.UnixMilli(2500) - // Resolve 默认号会触发懒过期恢复。 - resolved, err := svc.ResolveDisplayUserID(context.Background(), "100001") - if err != nil { - t.Fatalf("default display_user_id should recover after pretty expires: %v", err) - } - if resolved.DisplayUserID != "100001" || resolved.DisplayUserIDKind != userdomain.DisplayUserIDKindDefault { - t.Fatalf("unexpected recovered identity: %+v", resolved) - } - if _, err := svc.ResolveDisplayUserID(context.Background(), "888888"); !xerr.IsCode(err, xerr.DisplayUserIDNotFound) { - t.Fatalf("expired pretty display_user_id should not resolve, got %v", err) - } -} diff --git a/services/user-service/internal/storage/mysql/region/countries.go b/services/user-service/internal/storage/mysql/region/countries.go index f6b1a837..c002e0e7 100644 --- a/services/user-service/internal/storage/mysql/region/countries.go +++ b/services/user-service/internal/storage/mysql/region/countries.go @@ -6,11 +6,12 @@ import ( "errors" "strings" - mysqldriver "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" userdomain "hyapp/services/user-service/internal/domain/user" "hyapp/services/user-service/internal/storage/mysql/shared" + + mysqldriver "github.com/go-sql-driver/mysql" ) func (r *Repository) ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error) { @@ -20,7 +21,8 @@ func (r *Repository) ResolveEnabledCountryByCode(ctx context.Context, countryCod // ListRegistrationCountries 返回注册页可选国家,只包含 enabled 的国家。 func (r *Repository) ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error) { - return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: new(true)}) + enabled := true + return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: &enabled}) } // ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无显式映射时由 regions.go 返回 GLOBAL。 @@ -130,11 +132,6 @@ func scanCountry(scanner interface{ Scan(dest ...any) error }) (userdomain.Count return country, err } -//go:fix inline -func boolPtr(value bool) *bool { - return new(value) -} - func canonicalCountryCodes(ctx context.Context, q queryer, countries []string) ([]string, error) { seen := make(map[string]struct{}, len(countries)) result := make([]string, 0, len(countries)) diff --git a/services/wallet-service/Dockerfile b/services/wallet-service/Dockerfile index 64a8d515..2ae78895 100644 --- a/services/wallet-service/Dockerfile +++ b/services/wallet-service/Dockerfile @@ -30,7 +30,7 @@ COPY services/wallet-service/configs/config.docker.yaml /app/config.yaml USER appuser -EXPOSE 13004 +EXPOSE 13004 13104 ENTRYPOINT ["/app/server"] CMD ["-config", "/app/config.yaml"] diff --git a/services/wallet-service/configs/config.docker.yaml b/services/wallet-service/configs/config.docker.yaml index 63d622c7..dbe647c5 100644 --- a/services/wallet-service/configs/config.docker.yaml +++ b/services/wallet-service/configs/config.docker.yaml @@ -8,5 +8,6 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13004" +health_http_addr: ":13104" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" mysql_auto_migrate: false diff --git a/services/wallet-service/configs/config.tencent.example.yaml b/services/wallet-service/configs/config.tencent.example.yaml index dcbee2c8..fa546d42 100644 --- a/services/wallet-service/configs/config.tencent.example.yaml +++ b/services/wallet-service/configs/config.tencent.example.yaml @@ -8,5 +8,6 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13004" +health_http_addr: ":13104" mysql_dsn: "USER:PASSWORD@tcp(TENCENT_CDB_HOST:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC&tls=false" mysql_auto_migrate: false diff --git a/services/wallet-service/configs/config.yaml b/services/wallet-service/configs/config.yaml index 5725e518..35d511f3 100644 --- a/services/wallet-service/configs/config.yaml +++ b/services/wallet-service/configs/config.yaml @@ -8,5 +8,6 @@ log: include_response_body: false max_payload_bytes: 2048 grpc_addr: ":13004" +health_http_addr: ":13104" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC" mysql_auto_migrate: false diff --git a/services/wallet-service/internal/app/app.go b/services/wallet-service/internal/app/app.go index 5c0a1e3d..7dc7fb37 100644 --- a/services/wallet-service/internal/app/app.go +++ b/services/wallet-service/internal/app/app.go @@ -11,6 +11,8 @@ import ( healthgrpc "google.golang.org/grpc/health/grpc_health_v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/grpchealth" + "hyapp/pkg/grpcshutdown" + "hyapp/pkg/healthhttp" "hyapp/pkg/logx" "hyapp/services/wallet-service/internal/config" walletservice "hyapp/services/wallet-service/internal/service/wallet" @@ -20,11 +22,12 @@ import ( // App 装配 wallet-service gRPC 入口。 type App struct { - server *grpc.Server - listener net.Listener - health *grpchealth.ServingChecker - mysqlRepo *mysqlstorage.Repository - closeOnce sync.Once + server *grpc.Server + listener net.Listener + health *grpchealth.ServingChecker + healthHTTP *healthhttp.Server + mysqlRepo *mysqlstorage.Repository + closeOnce sync.Once } // New 初始化 wallet-service。 @@ -47,19 +50,30 @@ func New(cfg config.Config) (*App, error) { server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service"))) svc := walletservice.New(repository) walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc)) - health := grpchealth.NewServingChecker("wallet-service") + health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{ + Name: "mysql", + Check: repository.Ping, + }) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health)) + healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health) + if err != nil { + _ = listener.Close() + _ = repository.Close() + return nil, err + } return &App{ - server: server, - listener: listener, - health: health, - mysqlRepo: repository, + server: server, + listener: listener, + health: health, + healthHTTP: healthHTTP, + mysqlRepo: repository, }, nil } // Run 启动 gRPC 服务。 func (a *App) Run() error { + a.runHealthHTTP() a.health.MarkServing() err := a.server.Serve(a.listener) a.health.MarkStopped() @@ -74,10 +88,31 @@ func (a *App) Run() error { func (a *App) Close() { a.closeOnce.Do(func() { a.health.MarkDraining() - a.server.GracefulStop() + grpcshutdown.GracefulStop(a.server, 15*time.Second) + a.closeHealthHTTP() if a.mysqlRepo != nil { // MySQL 连接池最后关闭,避免正在 drain 的扣费请求丢失提交能力。 _ = a.mysqlRepo.Close() } }) } + +func (a *App) runHealthHTTP() { + if a.healthHTTP == nil { + return + } + go func() { + if err := a.healthHTTP.Run(); err != nil { + logx.Error(context.Background(), "health_http_run_failed", err) + } + }() +} + +func (a *App) closeHealthHTTP() { + if a.healthHTTP == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = a.healthHTTP.Close(ctx) +} diff --git a/services/wallet-service/internal/config/config.go b/services/wallet-service/internal/config/config.go index 1551267c..b6b9b03c 100644 --- a/services/wallet-service/internal/config/config.go +++ b/services/wallet-service/internal/config/config.go @@ -13,6 +13,8 @@ type Config struct { NodeID string `yaml:"node_id"` Environment string `yaml:"environment"` GRPCAddr string `yaml:"grpc_addr"` + // HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址,不承载业务流量。 + HealthHTTPAddr string `yaml:"health_http_addr"` // MySQLDSN 是账户、交易、分录和 outbox 的必需存储连接串。 MySQLDSN string `yaml:"mysql_dsn"` // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 @@ -27,6 +29,7 @@ func Default() Config { NodeID: "wallet-local", Environment: "local", GRPCAddr: ":13004", + HealthHTTPAddr: ":13104", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC", MySQLAutoMigrate: false, Log: logx.Config{ @@ -60,6 +63,10 @@ func Load(path string) (Config, error) { if cfg.Environment == "" { cfg.Environment = "local" } + cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr) + if cfg.HealthHTTPAddr == "" { + cfg.HealthHTTPAddr = ":13104" + } return cfg, nil }