diff --git a/.env.example b/.env.example index f115268..5076afe 100755 --- a/.env.example +++ b/.env.example @@ -23,6 +23,15 @@ JWT_SECRET=your-secret-key-change-in-production SHORT_VIDEO_API_KEY=replace-with-real-key SHORT_VIDEO_FREE_QUOTA=20 SHORT_VIDEO_TIMEOUT_SECONDS=5 +# 媒体代理配置(用于中转下载,绕过微信域名限制) +SHORT_VIDEO_PROXY_ENABLED=true +# 允许代理的域名白名单(留空表示允许所有,不推荐) +# 多个域名用逗号分隔,例如:cdn.example.com,video.example.com +SHORT_VIDEO_PROXY_ALLOWED_DOMAINS= +# 代理文件最大大小(MB) +SHORT_VIDEO_PROXY_MAX_SIZE_MB=100 +# 代理请求超时(秒) +SHORT_VIDEO_PROXY_TIMEOUT_SECONDS=60 # AI 配置(OpenAI-compatible) # 例:OpenAI = https://api.openai.com/v1 diff --git a/config/config.go b/config/config.go index 525a1a9..41a3812 100755 --- a/config/config.go +++ b/config/config.go @@ -49,6 +49,11 @@ type ShortVideoConfig struct { APIKey string FreeDailyQuota int RequestTimeout time.Duration + // 代理相关配置 + ProxyEnabled bool // 是否启用媒体代理 + ProxyAllowedDomains []string // 允许代理的域名白名单(为空表示允许所有) + ProxyMaxSize int64 // 代理文件最大大小(字节),0 表示不限制 + ProxyTimeout time.Duration // 代理请求超时时间 } // AIConfig 用于“AI 建议/问答”等能力的通用配置(OpenAI-compatible)。 @@ -122,9 +127,13 @@ func LoadConfig() { Expire: 86400, // 24小时 }, ShortVideo: ShortVideoConfig{ - APIKey: getEnv("SHORT_VIDEO_API_KEY", ""), - FreeDailyQuota: getEnvAsInt("SHORT_VIDEO_FREE_QUOTA", 20), - RequestTimeout: time.Duration(getEnvAsInt("SHORT_VIDEO_TIMEOUT_SECONDS", 5)) * time.Second, + APIKey: getEnv("SHORT_VIDEO_API_KEY", ""), + FreeDailyQuota: getEnvAsInt("SHORT_VIDEO_FREE_QUOTA", 20), + RequestTimeout: time.Duration(getEnvAsInt("SHORT_VIDEO_TIMEOUT_SECONDS", 5)) * time.Second, + ProxyEnabled: getEnvAsBool("SHORT_VIDEO_PROXY_ENABLED", true), + ProxyAllowedDomains: getEnvAsStringSlice("SHORT_VIDEO_PROXY_ALLOWED_DOMAINS", nil), + ProxyMaxSize: int64(getEnvAsInt("SHORT_VIDEO_PROXY_MAX_SIZE_MB", 100)) * 1024 * 1024, + ProxyTimeout: time.Duration(getEnvAsInt("SHORT_VIDEO_PROXY_TIMEOUT_SECONDS", 60)) * time.Second, }, AI: AIConfig{ BaseURL: getEnv("AI_BASE_URL", "https://api.openai.com/v1"), @@ -198,3 +207,28 @@ func getEnvAsInt(key string, defaultValue int) int { } return defaultValue } + +func getEnvAsBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + v := strings.ToLower(value) + return v == "true" || v == "1" || v == "yes" + } + return defaultValue +} + +func getEnvAsStringSlice(key string, defaultValue []string) []string { + if value := os.Getenv(key); value != "" { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + if len(result) > 0 { + return result + } + } + return defaultValue +} diff --git a/docs/bomb_game/PRD.md b/docs/bomb_game/PRD.md new file mode 100644 index 0000000..fa8105f --- /dev/null +++ b/docs/bomb_game/PRD.md @@ -0,0 +1,700 @@ +# 炸弹传递小游戏 - 产品需求文档 (PRD) + +## 1. 产品概述 + +### 1.1 产品定位 +一款基于微信小程序的多人实时互动小游戏。玩家通过邀请好友进入房间,在倒计时内通过摇晃手机传递"炸弹",炸弹爆炸时持有者即为输家。玩法简单刺激,适合朋友间聚会娱乐。 + +### 1.2 核心价值 +- **社交互动**:通过微信邀请好友,增强社交黏性 +- **紧张刺激**:倒计时 + 随机爆炸机制带来紧张感 +- **操作简单**:摇一摇即可参与,零学习成本 +- **即时反馈**:所有人实时看到炸弹传递与爆炸结果 + +### 1.3 目标用户 +- 微信用户群体(朋友聚会、线上社交) +- 喜欢轻量级派对游戏的玩家 +- 2~8 人小团体 + +--- + +## 2. 游戏规则 + +### 2.1 基本规则 + +| 项目 | 说明 | +|------|------| +| 玩家人数 | 2 ~ 8 人 | +| 游戏时长 | 每轮倒计时 **30 秒**(可配置) | +| 传递方式 | 持有炸弹的玩家 **摇晃手机达到指定次数** 后,炸弹传递给下一位玩家 | +| 摇晃次数 | 每次传递随机生成 **3 ~ 8 次**(可配置),玩家需完成对应摇晃次数 | +| 爆炸机制 | 倒计时结束时,持有炸弹的玩家被炸中 | +| 传递顺序 | 按房间内玩家座位顺序(顺时针)传递 | +| 胜负判定 | 炸弹爆炸时持有者为本轮输家,其余玩家获胜 | + +### 2.2 特殊规则 + +| 规则 | 说明 | +|------|------| +| 掉线处理 | 玩家掉线 **10 秒** 内未重连,视为自动弃权,炸弹跳过该玩家传给下一位 | +| 最少人数 | 房间内至少 **2 人** 才能开始游戏 | +| 房主特权 | 房主可以 **开始游戏**、**踢出玩家**、**调整设置** | +| 多轮机制 | 一轮结束后,房主可发起下一轮,输家作为下一轮炸弹初始持有者 | + +### 2.3 计分规则(可选扩展) + +| 事件 | 积分 | +|------|------| +| 存活(未被炸中) | +10 分 | +| 被炸中 | +0 分 | +| 连续存活 3 轮 | 额外 +5 分 | +| 多轮游戏结束后积分最高者为总冠军 | - | + +--- + +## 3. 游戏流程 + +### 3.1 房间阶段 + +``` +玩家 A 打开小程序 + ↓ +创建房间 → 获得房间号 / 邀请链接 + ↓ +分享邀请链接给微信好友 + ↓ +好友点击链接 → 进入房间等候区 + ↓ +等候区实时显示:已加入玩家列表、头像、准备状态 + ↓ +房主点击「开始游戏」(需 ≥ 2 人) + ↓ +进入游戏阶段 +``` + +### 3.2 游戏阶段 + +``` +服务端初始化本轮游戏: + ├── 随机选定炸弹初始持有者(首轮随机,后续轮为上轮输家) + ├── 生成本轮倒计时时长(默认 30s) + ├── 生成首次传递需要的摇晃次数(3~8 次) + └── 广播「游戏开始」消息给所有玩家 + ↓ +所有玩家屏幕显示: + ├── 倒计时(全局同步) + ├── 玩家列表(圆形排列) + ├── 炸弹当前位置(高亮 + 动画) + └── 持有者显示:需要摇晃的次数 + ↓ +[炸弹持有者操作] + ├── 摇晃手机 → 客户端检测摇晃 → 上报摇晃事件 + ├── 服务端计数 → 达到目标次数 + ├── 服务端判定传递 → 炸弹转移给下一位 + ├── 广播「炸弹传递」消息(包含新持有者、新摇晃目标次数) + └── 循环直到倒计时结束 + ↓ +[倒计时归零] + ├── 服务端判定当前持有者为输家 + ├── 广播「炸弹爆炸」消息(包含输家信息) + ├── 所有人屏幕播放爆炸动画 + 显示 "XXX 被炸飞了!" + └── 3 秒后进入结算界面 + ↓ +[结算界面] + ├── 显示本轮结果(输家、存活者) + ├── 显示累计积分排行(如开启计分) + ├── 房主可点击「再来一轮」 + └── 玩家可点击「退出房间」 +``` + +### 3.3 异常流程 + +``` +[玩家掉线] + ├── 服务端检测 WebSocket 断开 + ├── 标记玩家为「掉线」状态 + ├── 等待 10 秒重连 + ├── 重连成功 → 恢复游戏状态 + └── 重连失败 → 踢出房间,炸弹跳过该玩家 + +[房主掉线] + ├── 等待 10 秒重连 + ├── 重连失败 → 自动转让房主给下一位玩家 + └── 如果房间内只剩 1 人 → 游戏结束,解散房间 + +[游戏中玩家不足] + ├── 游戏进行中玩家数 < 2 + └── 游戏自动结束,剩余玩家获胜 +``` + +--- + +## 4. 页面设计 + +### 4.1 首页(游戏大厅) + +| 元素 | 说明 | +|------|------| +| 用户头像 + 昵称 | 微信授权信息 | +| 「创建房间」按钮 | 创建新游戏房间 | +| 「加入房间」按钮 | 输入房间号加入 | +| 游戏规则说明 | 简要规则介绍 | +| 历史战绩 | 总局数、胜率、最长连胜(扩展) | + +### 4.2 房间等候页 + +| 元素 | 说明 | +|------|------| +| 房间号 | 6 位数字,可复制 | +| 邀请按钮 | 微信分享邀请链接 | +| 玩家列表 | 头像 + 昵称 + 准备状态,圆形排列 | +| 房间设置 | 倒计时时长、摇晃次数范围(房主可调) | +| 开始游戏按钮 | 仅房主可见,≥ 2 人时可点击 | +| 退出房间按钮 | 所有人可见 | + +### 4.3 游戏进行页 + +| 元素 | 说明 | +|------|------| +| 倒计时 | 顶部居中,大字体,< 5 秒时变红闪烁 | +| 玩家环形排列 | 头像圆形排列,当前持有者高亮 + 炸弹图标 | +| 炸弹动画 | 炸弹在玩家之间传递的动画效果 | +| 摇晃提示 | 持有者屏幕显示「摇一摇!还需 X 次」 | +| 摇晃进度 | 进度条或数字展示已摇次数 / 目标次数 | +| 振动反馈 | 每次有效摇晃触发手机振动 | + +### 4.4 爆炸结算页 + +| 元素 | 说明 | +|------|------| +| 爆炸动画 | 全屏爆炸特效 | +| 输家展示 | 大头像 + "XXX 被炸飞了!" | +| 本轮结果 | 所有玩家结果列表(存活 / 被炸) | +| 积分排行 | 多轮游戏时的累计积分 | +| 「再来一轮」 | 房主按钮 | +| 「退出房间」 | 所有人可点 | +| 「分享战绩」 | 生成分享卡片 | + +--- + +## 5. 技术架构 + +### 5.1 通信方案 + +游戏需要 **实时双向通信**,采用 **WebSocket** 作为核心通信协议: + +``` +┌──────────────┐ WebSocket ┌──────────────────┐ +│ 微信小程序 │ ◄──────────────► │ Go 后端服务 │ +│ (前端客户端) │ │ (Gin + gorilla) │ +└──────────────┘ └────────┬─────────┘ + │ + ┌────────┴─────────┐ + │ Redis │ + │ (房间状态/Pub-Sub)│ + └────────┬─────────┘ + │ + ┌────────┴─────────┐ + │ MySQL │ + │ (用户/战绩持久化) │ + └──────────────────┘ +``` + +**为什么选择 WebSocket:** +- 游戏需要服务端主动推送(倒计时同步、炸弹传递、爆炸通知) +- HTTP 轮询延迟高、开销大,不适合实时游戏 +- 微信小程序原生支持 `wx.connectSocket` WebSocket API + +### 5.2 系统架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Go 后端服务 │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ REST API │ │ WebSocket │ │ Game Engine │ │ +│ │ (Gin) │ │ Hub │ │ (房间/游戏逻辑) │ │ +│ │ │ │ │ │ │ │ +│ │ - 创建房间 │ │ - 连接管理 │ │ - 房间状态机 │ │ +│ │ - 加入房间 │ │ - 消息路由 │ │ - 倒计时管理 │ │ +│ │ - 用户信息 │ │ - 心跳检测 │ │ - 摇晃计数 │ │ +│ │ - 战绩查询 │ │ - 断线重连 │ │ - 炸弹传递逻辑 │ │ +│ └─────────────┘ └─────────────┘ │ - 爆炸判定 │ │ +│ └─────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 数据层 │ │ +│ │ Redis: 房间实时状态、玩家会话、Pub/Sub 消息广播 │ │ +│ │ MySQL: 用户信息、游戏记录、战绩统计 │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 5.3 房间状态机 + +``` + 创建房间 + │ + ▼ + ┌───────────────┐ + │ WAITING │ ←── 玩家加入 / 离开 + │ (等待中) │ + └───────┬───────┘ + │ 房主点击开始(≥ 2 人) + ▼ + ┌───────────────┐ + │ COUNTDOWN │ + │ (倒计时准备) │ 3, 2, 1... + └───────┬───────┘ + │ + ▼ + ┌───────────────┐ + │ PLAYING │ ←── 摇晃 / 传递 / 倒计时 + │ (游戏中) │ + └───────┬───────┘ + │ 倒计时归零 / 玩家不足 + ▼ + ┌───────────────┐ + │ EXPLODED │ + │ (已爆炸) │ 展示结果 + └───────┬───────┘ + │ 3 秒后 + ▼ + ┌───────────────┐ 房主再来一轮 + │ FINISHED │ ──────────────────► WAITING + │ (已结束) │ + └───────┬───────┘ + │ 所有人退出 + ▼ + ┌───────────────┐ + │ DISSOLVED │ + │ (已解散) │ + └───────────────┘ +``` + +--- + +## 6. 数据模型 + +### 6.1 数据库表结构 + +#### bomb_game_rooms(游戏房间) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT, PK | 主键 | +| room_code | VARCHAR(6), UNIQUE | 房间号(6 位数字) | +| owner_id | BIGINT, FK → users.id | 房主用户 ID | +| status | ENUM | 房间状态:waiting / playing / finished / dissolved | +| max_players | TINYINT | 最大玩家数,默认 8 | +| round_duration | INT | 每轮倒计时秒数,默认 30 | +| shake_min | INT | 最少摇晃次数,默认 3 | +| shake_max | INT | 最多摇晃次数,默认 8 | +| current_round | INT | 当前轮数 | +| mini_program_id | BIGINT, FK | 所属小程序 ID | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +#### bomb_game_players(房间玩家) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT, PK | 主键 | +| room_id | BIGINT, FK → bomb_game_rooms.id | 房间 ID | +| user_id | BIGINT, FK → users.id | 用户 ID | +| seat_index | TINYINT | 座位序号(0 开始,决定传递顺序) | +| is_alive | BOOL | 本轮是否存活 | +| is_online | BOOL | 是否在线 | +| score | INT | 累计积分 | +| joined_at | DATETIME | 加入时间 | + +#### bomb_game_rounds(游戏轮次记录) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT, PK | 主键 | +| room_id | BIGINT, FK → bomb_game_rooms.id | 房间 ID | +| round_number | INT | 第几轮 | +| loser_id | BIGINT, FK → users.id | 输家用户 ID | +| bomb_start_player_id | BIGINT | 炸弹初始持有者 | +| total_passes | INT | 本轮总传递次数 | +| duration | INT | 实际持续秒数 | +| started_at | DATETIME | 开始时间 | +| ended_at | DATETIME | 结束时间 | + +#### bomb_game_stats(玩家战绩统计) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | BIGINT, PK | 主键 | +| user_id | BIGINT, FK → users.id | 用户 ID | +| mini_program_id | BIGINT, FK | 所属小程序 ID | +| total_games | INT | 总局数 | +| total_wins | INT | 获胜局数 | +| total_losses | INT | 失败局数 | +| win_streak | INT | 当前连胜 | +| max_win_streak | INT | 最长连胜 | +| total_score | INT | 累计积分 | +| updated_at | DATETIME | 最后更新时间 | + +### 6.2 Redis 数据结构(实时游戏状态) + +游戏进行中的状态存储在 Redis 中,保证实时性能: + +``` +# 房间实时状态 (Hash) +bomb:room:{room_code} = { + "status": "playing", + "current_holder": "user_id_123", // 当前炸弹持有者 + "next_player": "user_id_456", // 下一个接收者 + "shake_target": 5, // 当前需要摇晃的次数 + "shake_current": 2, // 已摇晃次数 + "round": 1, // 当前轮数 + "timer_end": 1706000030, // 倒计时结束时间戳 + "exploded": false // 是否已爆炸 +} + +# 房间玩家有序列表 (Sorted Set, score = seat_index) +bomb:room:{room_code}:players = { + "user_id_1": 0, + "user_id_2": 1, + "user_id_3": 2, + ... +} + +# 玩家连接映射 (Hash) - 用于断线重连 +bomb:player:{user_id} = { + "room_code": "123456", + "conn_id": "ws_conn_xxx" +} + +# 房间 TTL: 房间创建后 2 小时未活动自动过期清理 +``` + +--- + +## 7. API 设计 + +### 7.1 REST API(房间管理) + +基础路径:`/api/v1/bomb-game` + +#### 创建房间 + +``` +POST /api/v1/bomb-game/rooms +Authorization: Bearer + +Request Body: +{ + "max_players": 8, // 可选,默认 8 + "round_duration": 30, // 可选,默认 30 秒 + "shake_min": 3, // 可选,默认 3 + "shake_max": 8 // 可选,默认 8 +} + +Response 200: +{ + "code": 0, + "data": { + "room_code": "582916", + "room_id": 1, + "owner_id": 123, + "max_players": 8, + "round_duration": 30, + "share_ticket": "xxx" // 用于微信分享 + } +} +``` + +#### 加入房间 + +``` +POST /api/v1/bomb-game/rooms/:room_code/join +Authorization: Bearer + +Response 200: +{ + "code": 0, + "data": { + "room_code": "582916", + "seat_index": 2, + "players": [ + { + "user_id": 123, + "nickname": "张三", + "avatar_url": "https://...", + "seat_index": 0, + "is_owner": true + }, + ... + ] + } +} + +Error 400 (房间已满): +{ + "code": 40001, + "message": "房间已满" +} + +Error 400 (游戏已开始): +{ + "code": 40002, + "message": "游戏已开始,无法加入" +} +``` + +#### 获取房间信息 + +``` +GET /api/v1/bomb-game/rooms/:room_code +Authorization: Bearer + +Response 200: +{ + "code": 0, + "data": { + "room_code": "582916", + "status": "waiting", + "owner_id": 123, + "max_players": 8, + "round_duration": 30, + "current_round": 0, + "players": [ ... ] + } +} +``` + +#### 离开房间 + +``` +POST /api/v1/bomb-game/rooms/:room_code/leave +Authorization: Bearer + +Response 200: +{ + "code": 0, + "message": "已离开房间" +} +``` + +#### 获取个人战绩 + +``` +GET /api/v1/bomb-game/stats +Authorization: Bearer + +Response 200: +{ + "code": 0, + "data": { + "total_games": 42, + "total_wins": 30, + "total_losses": 12, + "win_rate": 71.4, + "win_streak": 3, + "max_win_streak": 7, + "total_score": 380 + } +} +``` + +### 7.2 WebSocket 连接 + +#### 连接建立 + +``` +ws:///api/v1/bomb-game/ws?room_code=582916&token= +``` + +连接建立后,服务端进行身份验证并将玩家加入房间的 WebSocket 广播组。 + +#### 心跳机制 + +``` +客户端每 5 秒发送: { "type": "ping" } +服务端响应: { "type": "pong" } + +超过 15 秒未收到心跳 → 标记为掉线 +``` + +--- + +## 8. WebSocket 消息协议 + +所有消息均为 JSON 格式,基本结构: + +```json +{ + "type": "message_type", + "data": { ... }, + "timestamp": 1706000000 +} +``` + +### 8.1 客户端 → 服务端 消息 + +| type | 说明 | data 字段 | +|------|------|-----------| +| `ping` | 心跳 | 无 | +| `shake` | 上报一次摇晃 | `{}` | +| `start_game` | 房主开始游戏 | `{}` | +| `next_round` | 房主发起下一轮 | `{}` | +| `kick_player` | 房主踢人 | `{ "user_id": 456 }` | +| `emoji` | 发送表情互动 | `{ "emoji_id": "laugh" }` | + +### 8.2 服务端 → 客户端 消息 + +| type | 说明 | data 字段 | +|------|------|-----------| +| `pong` | 心跳响应 | 无 | +| `room_update` | 房间信息更新(玩家加入/离开) | `{ "players": [...], "owner_id": 123 }` | +| `game_countdown` | 游戏即将开始倒计时 | `{ "countdown": 3 }` | +| `game_start` | 游戏正式开始 | `{ "round": 1, "bomb_holder": "user_id", "shake_target": 5, "timer_end": 1706000030 }` | +| `shake_update` | 摇晃进度更新 | `{ "user_id": "xxx", "current": 3, "target": 5 }` | +| `bomb_pass` | 炸弹传递 | `{ "from": "user_id_1", "to": "user_id_2", "shake_target": 4 }` | +| `bomb_explode` | 炸弹爆炸 | `{ "loser_id": "user_id", "loser_nickname": "张三" }` | +| `round_result` | 轮次结算 | `{ "round": 1, "loser": {...}, "scores": [...] }` | +| `player_offline` | 玩家掉线 | `{ "user_id": "xxx" }` | +| `player_reconnect` | 玩家重连 | `{ "user_id": "xxx" }` | +| `game_state_sync` | 完整状态同步(重连用) | `{ "room": {...}, "game": {...} }` | +| `emoji_broadcast` | 表情广播 | `{ "user_id": "xxx", "emoji_id": "laugh" }` | +| `error` | 错误消息 | `{ "code": 50001, "message": "..." }` | +| `owner_changed` | 房主变更 | `{ "new_owner_id": 456 }` | +| `room_dissolved` | 房间解散 | `{ "reason": "房主退出" }` | + +### 8.3 消息流转时序 + +#### 正常游戏流程时序: + +``` + 玩家A(房主) 服务端 玩家B 玩家C + │ │ │ │ + │ start_game │ │ │ + │─────────────►│ │ │ + │ │ game_countdown(3) │ + │◄─────────────│─────────────►│─────────────►│ + │ │ game_countdown(2) │ + │◄─────────────│─────────────►│─────────────►│ + │ │ game_countdown(1) │ + │◄─────────────│─────────────►│─────────────►│ + │ │ │ │ + │ │ game_start(holder=B, shake=5)│ + │◄─────────────│─────────────►│─────────────►│ + │ │ │ │ + │ │ shake │ │ + │ │◄─────────────│ │ + │ │ shake_update(current=1) │ + │◄─────────────│─────────────►│─────────────►│ + │ │ ... │ │ + │ │ shake(第5次) │ + │ │◄─────────────│ │ + │ │ │ │ + │ │ bomb_pass(B→C, shake=4) │ + │◄─────────────│─────────────►│─────────────►│ + │ │ │ │ + │ │ [倒计时归零] │ + │ │ │ │ + │ │ bomb_explode(loser=C) │ + │◄─────────────│─────────────►│─────────────►│ + │ │ │ │ + │ │ round_result(...) │ + │◄─────────────│─────────────►│─────────────►│ +``` + +--- + +## 9. 防作弊设计 + +### 9.1 服务端权威原则 + +| 策略 | 说明 | +|------|------| +| 摇晃验证 | 客户端上报摇晃事件,**服务端计数**,不信任客户端计数 | +| 频率限制 | 单次摇晃事件最小间隔 **200ms**,防止脚本快速刷摇晃 | +| 倒计时服务端控制 | 客户端倒计时仅为展示,**爆炸判定由服务端触发** | +| 传递判定 | 炸弹传递逻辑在服务端执行,客户端无法伪造传递 | + +### 9.2 其他安全措施 + +| 措施 | 说明 | +|------|------| +| WebSocket 鉴权 | 连接时验证 JWT token | +| 房间权限 | 只有房间内玩家可收发该房间消息 | +| 操作校验 | 只有当前持有者可上报摇晃事件 | +| 房主操作校验 | 开始游戏、踢人等操作验证房主身份 | + +--- + +## 10. 性能与扩展性考量 + +### 10.1 并发处理 + +| 方面 | 方案 | +|------|------| +| 房间隔离 | 每个房间独立 goroutine 处理游戏逻辑,避免跨房间锁竞争 | +| 消息广播 | 使用 channel 实现房间内广播,每个房间维护独立的客户端列表 | +| 定时器 | 使用 `time.AfterFunc` 管理倒计时,避免全局 ticker 开销 | + +### 10.2 扩展性 + +| 方面 | 方案 | +|------|------| +| 多实例部署 | 通过 Redis Pub/Sub 实现跨实例房间消息广播 | +| 房间分配 | 使用 Redis 存储房间 ↔ 实例映射,支持负载均衡 | +| 状态恢复 | 服务重启后从 Redis 恢复房间状态 | + +### 10.3 资源清理 + +| 触发条件 | 清理动作 | +|----------|----------| +| 房间 2 小时无活动 | Redis key 自动过期,MySQL 标记 dissolved | +| 所有玩家退出 | 立即清理 Redis 数据 | +| 服务端定时任务 | 每 30 分钟扫描僵尸房间并清理 | + +--- + +## 11. 项目文件结构(建议) + +``` +internal/ +└── bomb_game/ + ├── handler/ + │ ├── room_handler.go # REST API: 创建/加入/离开房间、查询战绩 + │ └── ws_handler.go # WebSocket: 连接建立、消息路由 + ├── service/ + │ ├── room_service.go # 房间管理业务逻辑 + │ ├── game_service.go # 游戏核心逻辑(状态机、倒计时、爆炸判定) + │ └── stats_service.go # 战绩统计 + ├── model/ + │ ├── room.go # Room, Player 数据库模型 + │ ├── round.go # Round 数据库模型 + │ └── stats.go # Stats 数据库模型 + ├── hub/ + │ ├── hub.go # WebSocket Hub: 连接管理、消息广播 + │ ├── client.go # WebSocket Client: 单个连接封装 + │ └── message.go # 消息类型定义 + └── engine/ + ├── game_engine.go # 游戏引擎: 房间级游戏循环 + └── timer.go # 倒计时管理器 +``` + +路由注册文件: + +``` +internal/routes/ +└── bomb_game_routes.go # 路由注册 +``` + +--- + +## 12. 待扩展功能 + +- [ ] 观战模式:允许旁观者进入房间观看 +- [ ] 道具系统:缩短/延长倒计时、跳过本次传递、反转传递方向 +- [ ] 排行榜:全局积分排行、好友排行 +- [ ] 房间密码:私密房间需要密码才能加入 +- [ ] 语音互动:游戏中实时语音(微信实时通话组件) +- [ ] 惩罚机制:输家需完成随机挑战(真心话/大冒险) +- [ ] 自定义皮肤:炸弹皮肤、爆炸特效自定义 +- [ ] 匹配模式:随机匹配陌生人游戏 diff --git a/docs/remove_watermark/API.md b/docs/remove_watermark/API.md index 513281c..3f6cf71 100644 --- a/docs/remove_watermark/API.md +++ b/docs/remove_watermark/API.md @@ -121,3 +121,104 @@ curl -X POST 'http://127.0.0.1:8080/api/v1/video/remove_watermark/unlock' \ | `userAgent` | string,可选 | 报告端的 UA,默认取 HTTP 头 | 服务端会额外记录请求来源 IP(`client_ip`),并存入 `video_download_failures` 表,便于后续排查白名单或 CDN 问题。 + +## 6. 媒体代理下载 + +`GET /api/v1/video/proxy` + +该接口用于代理媒体资源(视频/图片等)的下载,使小程序可以通过当前服务域名下载资源,避免微信对第三方域名的限制。 + +| 项目 | 说明 | +| --- | --- | +| Header | 无需鉴权 | +| Query 参数 | `url` - 需要代理的原始媒体地址(必填,需 URL 编码) | +| 响应 | 流式返回原始媒体内容,Content-Type 与源文件一致 | + +curl 示例: + +```bash +# 代理视频下载 +curl -L 'http://127.0.0.1:8080/api/v1/video/proxy?url=https%3A%2F%2Fexample.com%2Fvideo.mp4' \ + -o video.mp4 + +# 代理图片下载 +curl -L 'http://127.0.0.1:8080/api/v1/video/proxy?url=https%3A%2F%2Fexample.com%2Fcover.jpg' \ + -o cover.jpg +``` + +小程序端使用示例: + +```javascript +// 使用 wx.downloadFile 下载 +const proxyUrl = 'https://your-domain.com/api/v1/video/proxy'; +const originalUrl = 'https://cdn.example.com/video.mp4'; + +wx.downloadFile({ + url: `${proxyUrl}?url=${encodeURIComponent(originalUrl)}`, + success(res) { + if (res.statusCode === 200) { + // 下载成功,res.tempFilePath 为临时文件路径 + wx.saveVideoToPhotosAlbum({ + filePath: res.tempFilePath, + success() { + wx.showToast({ title: '保存成功' }); + } + }); + } + }, + fail(err) { + console.error('下载失败', err); + } +}); +``` + +**成功响应** + +- HTTP 状态码:200 +- Content-Type:与源文件一致(如 `video/mp4`、`image/jpeg`) +- Body:媒体文件二进制流 + +**错误返回** + +| HTTP 码 | code | message | 说明 | +| --- | --- | --- | --- | +| 400 | 400 | `请求参数错误,缺少 url 参数` | 未传 `url` 参数 | +| 400 | 400 | `无效的代理地址` | URL 格式不正确或协议不支持 | +| 403 | 403 | `该域名不在允许列表中` | 目标域名未在白名单内 | +| 413 | 413 | `文件过大,超出限制` | 文件大小超过配置的最大值 | +| 502 | 502 | `上游服务返回错误` | 源服务器返回非 2xx 状态码 | +| 503 | 503 | `代理服务未启用` | 代理功能被禁用 | +| 500 | 500 | `代理请求失败` | 其他内部错误 | + +**配置项** + +可通过环境变量配置代理行为: + +| 环境变量 | 默认值 | 说明 | +| --- | --- | --- | +| `SHORT_VIDEO_PROXY_ENABLED` | `true` | 是否启用代理功能 | +| `SHORT_VIDEO_PROXY_ALLOWED_DOMAINS` | 空(允许所有) | 允许代理的域名白名单,多个用逗号分隔 | +| `SHORT_VIDEO_PROXY_MAX_SIZE_MB` | `100` | 代理文件最大大小(MB) | +| `SHORT_VIDEO_PROXY_TIMEOUT_SECONDS` | `60` | 代理请求超时时间(秒) | + +配置示例(`.env`): + +```bash +# 启用代理 +SHORT_VIDEO_PROXY_ENABLED=true + +# 限制只能代理特定域名(安全推荐) +SHORT_VIDEO_PROXY_ALLOWED_DOMAINS=cdn.example.com,video.example.com,aweme.snssdk.com + +# 最大文件 200MB +SHORT_VIDEO_PROXY_MAX_SIZE_MB=200 + +# 超时 120 秒 +SHORT_VIDEO_PROXY_TIMEOUT_SECONDS=120 +``` + +**安全建议** + +1. **配置域名白名单**:强烈建议配置 `SHORT_VIDEO_PROXY_ALLOWED_DOMAINS`,只允许代理已知的 CDN 域名,防止被滥用为开放代理。 +2. **设置合理的文件大小限制**:根据实际需求设置 `SHORT_VIDEO_PROXY_MAX_SIZE_MB`,避免服务器带宽被大文件消耗。 +3. **监控带宽使用**:代理功能会消耗服务器带宽,建议监控流量并设置告警。 diff --git a/internal/remove_watermark/handler/video_handler.go b/internal/remove_watermark/handler/video_handler.go index b840dae..94a1a7b 100644 --- a/internal/remove_watermark/handler/video_handler.go +++ b/internal/remove_watermark/handler/video_handler.go @@ -2,8 +2,10 @@ package handler import ( "errors" + "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -36,6 +38,10 @@ type reportDownloadFailureRequest struct { UserAgent string `json:"userAgent"` } +type proxyMediaRequest struct { + URL string `form:"url" binding:"required"` +} + func (h *VideoHandler) RemoveWatermark(c *gin.Context) { var req removeWatermarkRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -136,3 +142,65 @@ func (h *VideoHandler) ReportDownloadFailure(c *gin.Context) { "reported": true, })) } + +// ProxyMedia 代理媒体资源下载 +// 该接口用于中转视频/图片等媒体资源,使小程序可以通过当前域名下载,避免微信域名限制 +func (h *VideoHandler) ProxyMedia(c *gin.Context) { + var req proxyMediaRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误,缺少 url 参数")) + return + } + + targetURL := strings.TrimSpace(req.URL) + if targetURL == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "url 参数不能为空")) + return + } + + // 调用 service 层进行代理 + result, err := h.videoService.ProxyMedia(c.Request.Context(), targetURL) + if err != nil { + switch { + case errors.Is(err, service.ErrProxyDisabled): + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "代理服务未启用")) + return + case errors.Is(err, service.ErrProxyInvalidURL): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "无效的代理地址")) + return + case errors.Is(err, service.ErrProxyDomainBlocked): + c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "该域名不在允许列表中")) + return + case errors.Is(err, service.ErrProxyFileTooLarge): + c.JSON(http.StatusRequestEntityTooLarge, model.Error(http.StatusRequestEntityTooLarge, "文件过大,超出限制")) + return + default: + var thirdPartyErr *service.ThirdPartyError + if errors.As(err, &thirdPartyErr) { + c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, "上游服务返回错误")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "代理请求失败")) + return + } + } + + // 确保关闭响应体 + defer result.Body.Close() + + // 设置响应头 + c.Header("Content-Type", result.ContentType) + if result.ContentLength > 0 { + c.Header("Content-Length", strconv.FormatInt(result.ContentLength, 10)) + } + // 允许跨域(如果需要) + c.Header("Access-Control-Allow-Origin", "*") + // 设置缓存头,减少重复请求 + c.Header("Cache-Control", "public, max-age=86400") + + // 流式输出响应体 + c.Status(result.StatusCode) + + // 使用 io.Copy 进行流式传输,避免一次性加载到内存 + _, _ = io.Copy(c.Writer, result.Body) +} diff --git a/internal/remove_watermark/service/video_service.go b/internal/remove_watermark/service/video_service.go index 57c7378..cf8fbcc 100644 --- a/internal/remove_watermark/service/video_service.go +++ b/internal/remove_watermark/service/video_service.go @@ -27,6 +27,10 @@ var ( ErrURLNotFound = errors.New("no valid url found in content") ErrShortVideoAPIKey = errors.New("short video api key is not configured") ErrDailyQuotaExceeded = errors.New("daily free quota exceeded, please watch an ad to continue") + ErrProxyDisabled = errors.New("media proxy is disabled") + ErrProxyDomainBlocked = errors.New("domain is not in the allowed list") + ErrProxyFileTooLarge = errors.New("file size exceeds the limit") + ErrProxyInvalidURL = errors.New("invalid proxy url") ) type VideoService struct { @@ -292,3 +296,131 @@ func (s *VideoService) ReportDownloadFailure(ctx context.Context, report Downloa } return nil } + +// ProxyMediaResult 代理媒体请求的结果 +type ProxyMediaResult struct { + Body io.ReadCloser + ContentType string + ContentLength int64 + StatusCode int +} + +// ProxyMedia 代理媒体资源下载,用于绕过微信小程序的域名限制 +// 该方法会验证目标URL是否在允许的域名白名单内,并流式转发响应 +func (s *VideoService) ProxyMedia(ctx context.Context, targetURL string) (*ProxyMediaResult, error) { + // 检查是否启用代理功能 + if !s.cfg.ProxyEnabled { + return nil, ErrProxyDisabled + } + + // 验证并解析 URL + parsed, err := url.Parse(targetURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, ErrProxyInvalidURL + } + + // 只允许 http/https 协议 + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, ErrProxyInvalidURL + } + + // 检查域名白名单(如果配置了的话) + if len(s.cfg.ProxyAllowedDomains) > 0 { + allowed := false + for _, domain := range s.cfg.ProxyAllowedDomains { + if strings.HasSuffix(parsed.Host, domain) || parsed.Host == domain { + allowed = true + break + } + } + if !allowed { + return nil, ErrProxyDomainBlocked + } + } + + // 创建带超时的 HTTP 客户端(代理请求可能需要更长时间) + proxyTimeout := s.cfg.ProxyTimeout + if proxyTimeout <= 0 { + proxyTimeout = 60 * time.Second + } + proxyClient := &http.Client{ + Timeout: proxyTimeout, + } + + // 构建代理请求 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) + if err != nil { + return nil, fmt.Errorf("build proxy request: %w", err) + } + + // 设置常见的请求头,模拟正常浏览器行为 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + req.Header.Set("Referer", fmt.Sprintf("%s://%s/", parsed.Scheme, parsed.Host)) + + // 发起请求 + resp, err := proxyClient.Do(req) + if err != nil { + return nil, fmt.Errorf("proxy request failed: %w", err) + } + + // 检查响应状态 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + resp.Body.Close() + return nil, &ThirdPartyError{ + StatusCode: resp.StatusCode, + Message: fmt.Sprintf("upstream returned status %d", resp.StatusCode), + } + } + + // 检查文件大小限制 + if s.cfg.ProxyMaxSize > 0 && resp.ContentLength > s.cfg.ProxyMaxSize { + resp.Body.Close() + return nil, ErrProxyFileTooLarge + } + + // 获取 Content-Type + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + return &ProxyMediaResult{ + Body: resp.Body, + ContentType: contentType, + ContentLength: resp.ContentLength, + StatusCode: resp.StatusCode, + }, nil +} + +// ValidateProxyURL 验证代理URL是否有效(不实际请求,只做格式和白名单检查) +func (s *VideoService) ValidateProxyURL(targetURL string) error { + if !s.cfg.ProxyEnabled { + return ErrProxyDisabled + } + + parsed, err := url.Parse(targetURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return ErrProxyInvalidURL + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return ErrProxyInvalidURL + } + + if len(s.cfg.ProxyAllowedDomains) > 0 { + allowed := false + for _, domain := range s.cfg.ProxyAllowedDomains { + if strings.HasSuffix(parsed.Host, domain) || parsed.Host == domain { + allowed = true + break + } + } + if !allowed { + return ErrProxyDomainBlocked + } + } + + return nil +} diff --git a/internal/routes/remove_watermark_routes.go b/internal/routes/remove_watermark_routes.go index d263e0f..bdd497c 100644 --- a/internal/routes/remove_watermark_routes.go +++ b/internal/routes/remove_watermark_routes.go @@ -12,4 +12,6 @@ func registerRemoveWatermarkRoutes(api *gin.RouterGroup, protected *gin.RouterGr protected.POST("/video/remove_watermark/unlock", videoHandler.UnlockQuota) // 下载失败上报:供其他服务调用,无需鉴权 api.POST("/video/remove_watermark/report_failure", videoHandler.ReportDownloadFailure) + // 媒体代理:用于中转视频/图片等资源,绕过微信域名限制,无需鉴权 + api.GET("/video/proxy", videoHandler.ProxyMedia) }