Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

- 支持在构建期预置默认服务器地址:打包机放一个 `client/.env.local`(已被 gitignore)填 `VITE_DEFAULT_SERVER_URL`,打出的安装包首次启动即指向该地址,玩家不必手填。公开仓库与 CI 不含该文件,产物保持 `http://127.0.0.1:8787`。该值只在生产构建生效,`vite dev` 与单元测试始终使用本地地址。
- 设置窗口的服务器地址下方显示「当前默认」值,便于确认安装包预置的是哪个地址。
- 每用户图片存储配额(默认 64MB)与未引用图片 TTL 清理(默认 14 天);超限返回 `quota_exceeded`。
- 每日数据库备份可额外推到 S3 兼容对象存储(AWS / 腾讯云 COS / 阿里云 OSS / MinIO);未配置时行为不变。
- 好友关系加上无向唯一索引,A 申请 B 与 B 申请 A 不能插成两行;反向并发会自动变成互加。

## v0.8.0 — 2026-09-09

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ cd client && npm install && npm run tauri dev
npm test && npm run lint && npm run typecheck
```

测试基线:服务端 91 例(PGlite 内存库 + 真实 WebSocket 集成),客户端 44 例(含屏幕共享连接生命周期、自动档位、码率分摊与中继限速、默认服务器地址解析的单元测试)。仓库里另有 13 个双账号浏览器回归脚本(`dev/e2e-*.mjs`),覆盖私聊、编辑、表情、通知跳转、邀请链接、屏幕共享等流程;其中 `e2e-screen-share-media.mjs` 用合成采集流跑真实的 P2P 媒体链路(断言画面推进与音频 RMS),不需要人工点选择器。
测试基线:服务端 105 例(PGlite 内存库 + 真实 WebSocket 集成,含图片配额/TTL 与 S3 兼容备份上传),客户端 45 例(含屏幕共享连接生命周期、自动档位、码率分摊与中继限速、默认服务器地址解析的单元测试)。仓库里另有 13 个双账号浏览器回归脚本(`dev/e2e-*.mjs`),覆盖私聊、编辑、表情、通知跳转、邀请链接、屏幕共享等流程;其中 `e2e-screen-share-media.mjs` 用合成采集流跑真实的 P2P 媒体链路(断言画面推进与音频 RMS),不需要人工点选择器。

数据库结构由 16 个纯 SQL 迁移文件定义(`server/migrations/`),启动时按序自动应用。开发用的 PGlite 和生产 PostgreSQL 执行的是同一份 SQL。

Expand Down Expand Up @@ -430,7 +430,7 @@ GameTalk/
- [x] 邀请链接(有效期 / 次数 / 深链加入)
- [x] 房间内 P2P 屏幕共享 + 独立观看窗 + 自建 TURN 兜底
- [x] 通知点击跳转到对应会话
- [ ] 图片对象存储冷备(S3 / COS)
- [x] 图片对象存储冷备(S3 / COS / OSS / MinIO,可选)
- [ ] macOS / Linux 的游戏模式适配

## 参与贡献
Expand Down
13 changes: 13 additions & 0 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,16 @@ GAMETALK_HOST=chat.example.com
# TURN_URL 可多个,逗号分隔(建议同时给 UDP 与 TCP)
# TURN_SECRET=change-me-openssl-rand-hex-16
# TURN_URL=turn:你的服务器IP:3478,turn:你的服务器IP:3478?transport=tcp

# 可选:把每日数据库 dump 再推一份到 S3 兼容对象存储(图片字节在 dump 里)。
# 四项都填了 backup-db.sh 才会上传;不填则只保留服务器本地 14 天。
# BACKUP_S3_ENDPOINT=https://cos.ap-guangzhou.myqcloud.com
# BACKUP_S3_BUCKET=your-bucket
# BACKUP_S3_ACCESS_KEY=
# BACKUP_S3_SECRET_KEY=
# BACKUP_S3_REGION=ap-guangzhou
# BACKUP_S3_PREFIX=gametalk/
# BACKUP_S3_PATH_STYLE=true
# 可选:每用户图片上限(字节,默认 64MB)与未引用图片保留天数(默认 14)
# MEDIA_QUOTA_BYTES=67108864
# MEDIA_UNUSED_TTL_DAYS=14
40 changes: 40 additions & 0 deletions docker/backup-db.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,43 @@ find "$BACKUP_DIR" -name 'configs-*.tar.gz' -mtime +"$KEEP_DAYS" -delete

size=$(du -h "$dump" | cut -f1)
log "✅ 完成:$dump ($size, TOC $toc_lines 行) + $cfg;保留 ${KEEP_DAYS} 天"

# 5) 可选:推到 S3 兼容对象存储(AWS / COS / OSS / MinIO)。未配置四项凭据则跳过。
# 失败不回滚本地 dump——对象存储是额外的冷备,不能因为远端故障丢掉本机备份。
if [ -f "$REPO_ROOT/docker/.env" ]; then
set -a
# shellcheck disable=SC1091
. "$REPO_ROOT/docker/.env"
set +a
fi

upload_s3() {
local file="$1"
if [ -z "${BACKUP_S3_ENDPOINT:-}" ] || [ -z "${BACKUP_S3_BUCKET:-}" ] \
|| [ -z "${BACKUP_S3_ACCESS_KEY:-}" ] || [ -z "${BACKUP_S3_SECRET_KEY:-}" ]; then
return 0
fi
local server="${SERVER_CONTAINER:-docker-server-1}"
local remote="/tmp/gt-s3-$(basename "$file")"
if ! docker cp "$file" "$server:$remote" 2>/dev/null; then
log "⚠ 对象存储:无法把文件拷进 $server,跳过 $(basename "$file")"
return 0
fi
if docker exec \
-e BACKUP_S3_ENDPOINT -e BACKUP_S3_BUCKET \
-e BACKUP_S3_ACCESS_KEY -e BACKUP_S3_SECRET_KEY \
-e BACKUP_S3_REGION -e BACKUP_S3_PREFIX -e BACKUP_S3_PATH_STYLE \
"$server" node dist/cli/push-backup.js "$remote" "$(basename "$file")"; then
log "✅ 已上传到对象存储:$(basename "$file")"
else
log "⚠ 对象存储上传失败(本地 dump 仍保留):$(basename "$file")"
fi
docker exec "$server" rm -f "$remote" >/dev/null 2>&1 || true
}

if [ -n "${BACKUP_S3_BUCKET:-}" ]; then
upload_s3 "$dump"
upload_s3 "$cfg"
else
log "对象存储未配置(BACKUP_S3_*),仅保留本地 dump"
fi
2 changes: 2 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ services:
# 默认 *(桌面客户端不受浏览器同源限制,够用);如需收紧在 .env 里设置 CORS_ORIGIN
CORS_ORIGIN: ${CORS_ORIGIN:-*}
LOG_LEVEL: info
MEDIA_QUOTA_BYTES: ${MEDIA_QUOTA_BYTES:-67108864}
MEDIA_UNUSED_TTL_DAYS: ${MEDIA_UNUSED_TTL_DAYS:-14}
depends_on:
postgres:
condition: service_healthy
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ gametalk/
│ │ ├── routes/ # REST 路由(health / auth / rooms / invites / friends / dm / stickers / media / turn)
│ │ ├── ws/ # WS 网关(认证 + 房间广播 + 花名册/在线状态 + 信令透传 + 限流 + 心跳清理)
│ │ ├── db/ # pg/PGlite 抽象 + migration 执行器
│ │ └── lib/ # jwt / password / image / invite / avatar / validate / envfile
│ │ └── lib/ # jwt / password / image / invite / avatar / validate / envfile / mediaLifecycle / s3
│ ├── migrations/ # 纯 SQL migration(生产与 PGlite 同源)
│ └── test/ # vitest 单测 + 集成测试
├── docker/ # 生产部署 compose、Caddyfile、部署/备份/coturn 脚本
Expand Down Expand Up @@ -142,7 +142,7 @@ gametalk/

**表情包**:`POST/GET/DELETE /api/stickers`(个人云表情,媒体归属校验 + 24 上限 + 幂等)、`POST/GET/DELETE /api/rooms/:id/stickers`(房间共享,成员资格校验,删除 = 添加者或房主)。客户端表情面板三页签:表情 / 我的表情包(云同步,本地旧数据自动迁移)/ 群表情(按房间隔离)。

**好友**:`friendships`(pending/accepted,双向唯一);支持 userId / 用户名 / `#8 位短 ID` 查找;反向申请等价于互加。实时事件(`friend:request/accepted/declined/removed`)经 WS 推送在线方。好友与房间完全分离管理。
**好友**:`friendships`(pending/accepted)。`(requester_id, addressee_id)` 方向唯一之外,另有无向唯一索引 `(LEAST(requester, addressee), GREATEST(...))`,A→B 与 B→A 不能并存。反向申请等价于互加;反向并发插入撞唯一索引时再读对方那一行并自动接受。支持 userId / 用户名 / `#8 位短 ID` 查找。实时事件(`friend:request/accepted/declined/removed`)经 WS 推送在线方。好友与房间完全分离管理。

**好友私聊(DM)**:仅 accepted 好友可互发(`dm:send` 服务端校验,非好友回 `not_friends`)。独立 `dm_messages` 表(与房间消息分离,无提及/禁言语义),消息含 `from/to/username 快照/kind/media_url/reply_to/recalled`;持久化后向**双方所有连接**广播 `dm:new`(发送者自己也收到,多端一致)。撤回 `dm:recall` 仅发送者本人(无房主概念,他人撤回回 `only_sender`),广播 `dm:recalled`,内容清空。REST:`GET /api/dm/conversations`(DISTINCT ON 聚合每会话最后一条,侧栏预览一次拉齐)、`GET /api/dm/:peerId/messages`(游标分页,非好友 403)。删除好友不删历史(重新加好友后消息仍在,UI 隐藏会话)。客户端:乐观发送 + 按序校正,`activeDmPeerId` 与 `activeRoomId` 互斥表达活跃会话。

Expand Down Expand Up @@ -195,6 +195,7 @@ gametalk/
- `014_invite_links`:`invite_links`(16 位长码,可过期 `expires_at` / 可限次数 `max_uses` / `used_count` 计数,随房间级联删除)
- `015_forwarded_label`:`messages.forwarded_from_label` / `dm_messages.forwarded_from_label`(转发来源展示快照,纯展示不参与权限判断)
- `016_media_urls`:`messages.media_urls` / `dm_messages.media_urls`(多图消息的完整 URL 列表 JSONB,首图仍写 `media_url` 兼容旧客户端)
- `017_media_quota_friends_pair`:`media.owner_id` 索引;`friendships` 无向唯一索引(LEAST/GREATEST),并先按「已接受优先」去重

## 6. 游戏 Overlay(透明置顶窗口方案)

Expand Down Expand Up @@ -241,7 +242,7 @@ gametalk/
- 邀请码/邀请链接用 `crypto.randomInt` 生成(CSPRNG),不用 `Math.random`。
- 头像:上传 data URL 类型/大小(≤3MB)/魔数三重校验;分发走 `/api/avatars/:id`(公开端点,
id 为不可枚举 UUID),带 5 分钟缓存头。
- 消息图片:`POST /api/media` 需登录,类型/大小(≤5MB)/魔数三重校验;读取 `/api/media/:id`
- 消息图片:`POST /api/media` 需登录,类型/大小(≤5MB)/魔数三重校验;**每用户默认 64MB 配额**(`MEDIA_QUOTA_BYTES`,超限 413 `quota_exceeded`);未被消息或表情引用的图片默认 14 天后清理(`MEDIA_UNUSED_TTL_DAYS`,0 关闭)。读取 `/api/media/:id`
公开(`<img>` 无法附带认证头),id 为不可枚举 UUID + immutable 缓存;发送时校验媒体归属,群表情入库时同样校验归属。
- 注册并发竞态由用户名唯一索引兜底(冲突返回 409);邀请兑换用「占位入房 + 条件原子自增」防超额,并发下不会 500。
- CORS 可配置:compose 默认 `*`(桌面客户端不受浏览器同源限制),可经 `CORS_ORIGIN` 收紧。
Expand Down
32 changes: 30 additions & 2 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ nano .env && bash deploy.sh
| `CORS_ORIGIN` | 可选,默认 `*`(桌面客户端不受浏览器同源限制);需收紧时设置,多来源逗号分隔 |
| `TURN_SECRET` | 可选,自建 TURN 中继共享密钥(见第 9 节) |
| `TURN_URL` | 可选,TURN 地址(逗号分隔,建议同时给 UDP 与 TCP) |
| `MEDIA_QUOTA_BYTES` | 可选,每用户图片存储上限(字节,默认 67108864 = 64MB;`0` 不限制) |
| `MEDIA_UNUSED_TTL_DAYS` | 可选,未被消息/表情引用的图片保留天数(默认 14;`0` 不自动清理) |
| `BACKUP_S3_ENDPOINT` 等 | 可选,每日 dump 额外推到 S3 兼容对象存储(见第 6 节);四项凭据都填了才上传 |

### 数据迁移

Expand Down Expand Up @@ -197,6 +200,32 @@ schtasks /Create /F /TN "GameTalk 备份异地拉取" /SC DAILY /ST 09:10 `

- 建议每月做一次恢复演练(下述步骤在 VPS 上用最新 dump 验证行数即可,不必真删数据)。

### 对象存储冷备(S3 / COS / OSS / MinIO,可选)

`pg_dump` 已经包含 `media` 表里的图片字节。把 dump 再推一份到对象存储,等于数据库和图片一起有了异地冷备,不再依赖单台 Windows 管理机定时拉取。

在 `docker/.env` 填齐这四项(缺任何一项脚本都会跳过上传,只保留服务器本地 14 天):

```
BACKUP_S3_ENDPOINT=https://cos.<地域>.myqcloud.com # 或 oss-<地域>.aliyuncs.com / s3.<地域>.amazonaws.com / MinIO 地址
BACKUP_S3_BUCKET=<桶名>
BACKUP_S3_ACCESS_KEY=<密钥 ID>
BACKUP_S3_SECRET_KEY=<密钥>
BACKUP_S3_REGION=<地域> # 签名用,COS/OSS 填对应地域
BACKUP_S3_PREFIX=gametalk/ # 可选,对象键前缀
BACKUP_S3_PATH_STYLE=true # 默认路径风格 /bucket/key,MinIO 与大多数兼容层都用这个
```

`backup-db.sh` 在本地 dump 校验通过后,把文件拷进 server 容器,调用 `node dist/cli/push-backup.js` 做 SigV4 PUT。对象存储失败**不会丢掉本地 dump**(日志里会写警告)。手动补传:

```bash
docker cp /root/gametalk/backups/gametalk-db-XXXX.dump docker-server-1:/tmp/gt.dump
docker exec -e BACKUP_S3_ENDPOINT -e BACKUP_S3_BUCKET -e BACKUP_S3_ACCESS_KEY -e BACKUP_S3_SECRET_KEY \
docker-server-1 node dist/cli/push-backup.js /tmp/gt.dump gametalk-db-XXXX.dump
```

服务端另有每用户图片配额(默认 64MB,`MEDIA_QUOTA_BYTES`)和未引用图片 TTL(默认 14 天,`MEDIA_UNUSED_TTL_DAYS`),避免 BYTEA 把磁盘吃满;撤回后清空引用的图会在 TTL 后被清掉,表情包引用的图不会。

### 恢复步骤

```bash
Expand All @@ -208,8 +237,7 @@ docker exec -i docker-postgres-1 pg_restore -U gametalk -d gametalk --clean --if
docker compose start server # 3) 重启并验证 /health、登录、历史消息
```

> 可选升级:条件允许时将备份再推送一份到对象存储(S3/COS/OSS 免费额度即可),
> 把异地副本从「管理机定时拉取」升级为「服务器定时推送」,进一步降低对单台管理机的依赖。
> 对象存储冷备的配置与手动补传见上一小节;不填 `BACKUP_S3_*` 时行为与原来完全一样(只留服务器本地 dump + 管理机拉取)。

## 7. 客户端下载

Expand Down
8 changes: 6 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

| 范围 | 工具 | 内容 |
|---|---|---|
| 服务端单测/集成 | vitest | REST 路由、WS 网关(双客户端实时收发、房主删房、幂等 join、花名册/在线状态)、好友(申请/互加/删除 + 实时事件 + 在线广播)、好友私聊(非好友拒绝/双方广播/历史分页/会话聚合/撤回权限/图片与引用校验)、@提及(解析/成员校验/入库/编辑重算)、图片消息(上传校验/归属/绝对 URL/多图)、禁言(仅房主/到期/解除)、WS 加固(限流 / 超大帧断连)、REST 限流(登录 429)、头像端点(data URL → HTTP 端点)、邀请链接(创建/资格/过期 410/耗尽 410/幂等入房不计数/吊销权限/并发兑换)、消息转发(源可见性/目标唯一/代复制 media/来源标注;转发进房复用禁言校验)、屏幕共享信令(非成员拒绝 / 启停广播 / 定向转发与越权拒绝 / 晚加入快照)、migration 幂等、输入校验与错误映射 |
| 服务端单测/集成 | vitest | REST 路由、WS 网关(双客户端实时收发、房主删房、幂等 join、花名册/在线状态)、好友(申请/互加/删除 + 实时事件 + 在线广播 + 无向唯一索引)、好友私聊(非好友拒绝/双方广播/历史分页/会话聚合/撤回权限/图片与引用校验)、@提及(解析/成员校验/入库/编辑重算)、图片消息(上传校验/归属/绝对 URL/多图/每用户配额/未引用 TTL 清理)、禁言(仅房主/到期/解除)、WS 加固(限流 / 超大帧断连)、REST 限流(登录 429)、头像端点(data URL → HTTP 端点)、邀请链接(创建/资格/过期 410/耗尽 410/幂等入房不计数/吊销权限/并发兑换)、消息转发(源可见性/目标唯一/代复制 media/来源标注;转发进房复用禁言校验)、屏幕共享信令(非成员拒绝 / 启停广播 / 定向转发与越权拒绝 / 晚加入快照)、S3 兼容备份上传(SigV4 + 本地 mock PUT)、migration 幂等、输入校验与错误映射 |
| 服务端回归 | vitest | `test/regressions.test.ts`:历史分页保留最新一条、空 mediaUrls 不算内容、退房/删房后订阅失效、房主不可退房、目标不在房间用 `target_not_in_room`、私聊撤回清 media_urls、编辑重算提及、改名同步广播、群表情媒体归属、邀请并发兑换、非法 id 不 500、代理信任判定(17 例) |
| 客户端逻辑 | vitest | gameMode 管理器(mock Tauri,含换键后旧键注销、启动途中关闭的热键清理)、真实 server 集成测试(双端聊天 / not_in_room / 断线重连 / 花名册离线保留 / 好友私聊收发与历史 / 编辑往返) |
| 屏幕共享逻辑 | vitest | `test/screenShare.test.ts`:连接生命周期、重复 request 非破坏性、码率分摊与档位、ICE 断线重启、relay 候选命中限码率(12 例,mock RTCPeerConnection) |
Expand Down Expand Up @@ -69,8 +69,12 @@ cd client/src-tauri && cargo check
| 41 | 退房订阅清理对称性:REST 退房后不再收到该房间广播、花名册不再显示其在线 | ✅(session.test.ts 1 例) | ⬜ |
| 42 | 客户端顶号处理:提示 + 清凭据 + 不再自动重连(防两端互相顶号死循环) | ✅(store.regressions.test.ts 1 例) | ⬜ |
| 43 | 屏幕共享控制条布局体检(按 `CONTROL_W/H` 渲染真实 DOM,量溢出/换行/按钮压缩) | ✅(dev/probe-share-bar.mjs,失败退出码 1) | — |
| 44 | 每用户图片配额(超限 413 `quota_exceeded`,他人不受影响) | ✅(media.test.ts) | — |
| 45 | 未引用图片 TTL 清理(表情/消息附件保留,撤回后可清) | ✅(media.test.ts) | — |
| 46 | 好友无向唯一索引(SQL 层拒绝 A→B 与 B→A 两行) | ✅(friends.test.ts) | — |
| 47 | 备份对象存储上传(SigV4 签名稳定 + mock 端点 PUT 字节一致) | ✅(s3.test.ts) | — |

当前实测:server **98** 测试(12 文件)+ client **45** 测试(5 文件)全绿;lint 双工作区通过;生产模式冒烟(health/register/login)通过。生产环境(境外云服务器,地址不记录在本仓库)2026-09-11 运行 **0.8.0** 并验证(`/health` 版本、`/api/turn` 匿名 401、WSS 握手、容器 healthy、宝塔与其他站点不受影响),当日测试数据已全量清理(users/rooms/messages/dm_messages/media/invite_links 归零)。
当前实测:server **105** 测试(14 文件)+ client **45** 测试(5 文件)全绿;lint 双工作区通过;生产模式冒烟(health/register/login)通过。生产环境(境外云服务器,地址不记录在本仓库)2026-09-11 运行 **0.8.0** 并验证(`/health` 版本、`/api/turn` 匿名 401、WSS 握手、容器 healthy、宝塔与其他站点不受影响),当日测试数据已全量清理(users/rooms/messages/dm_messages/media/invite_links 归零)。

## 3. 真机验收清单

Expand Down
15 changes: 15 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,18 @@ RATE_LIMIT_AUTH_MAX=10
# 走中继时的单路码率上限(bps,默认 1200000)。按服务器出口带宽设置:
# 4Mbps 出口建议 1200000;30Mbps 出口可设 4000000
# TURN_RELAY_MAX_BPS=1200000

# 每用户图片存储上限(字节,默认 64MB)。0 表示不限制
# MEDIA_QUOTA_BYTES=67108864
# 未被消息/表情引用的图片保留天数(默认 14)。0 表示不自动清理
# MEDIA_UNUSED_TTL_DAYS=14

# 备份推送到 S3 兼容对象存储(AWS / 腾讯云 COS / 阿里云 OSS / MinIO)。
# 四项都填了,docker/backup-db.sh 才会在本地 dump 成功后额外上传一份。
# BACKUP_S3_ENDPOINT=https://cos.ap-guangzhou.myqcloud.com
# BACKUP_S3_BUCKET=your-bucket
# BACKUP_S3_ACCESS_KEY=
# BACKUP_S3_SECRET_KEY=
# BACKUP_S3_REGION=ap-guangzhou
# BACKUP_S3_PREFIX=gametalk/
# BACKUP_S3_PATH_STYLE=true
Loading