inint
This commit is contained in:
312
MIGRATION.md
Normal file
312
MIGRATION.md
Normal file
@@ -0,0 +1,312 @@
|
||||
# 清渊传奇 PHP → Vue + Node.js 移植计划
|
||||
|
||||
> **文档版本**:v1.2
|
||||
> **创建时间**:2026-03-16
|
||||
> **最后更新**:2026-03-16(v1.2)
|
||||
> **负责人**:待定
|
||||
|
||||
---
|
||||
|
||||
## 一、项目背景与目标
|
||||
|
||||
### 现状
|
||||
|
||||
本项目(清渊传奇 H5 游戏平台)目前处于**双轨制过渡期**:
|
||||
|
||||
- **旧版(PHP)**:基于 PHP 的传统服务端渲染架构,包含完整的业务逻辑(账号系统、区服管理、提现、第三方登录等)
|
||||
- **新版(Node.js + Vue 3)**:已存在 `module/server`(Koa)和 `module/web`(Vue 3 + Vite)骨架,部分接口已迁移
|
||||
|
||||
### 移植目标
|
||||
|
||||
将 PHP 单体后端全部迁移至 **Node.js(Koa)**,前端统一使用 **Vue 3 + Element Plus**,实现完整的前后端分离架构,废弃所有 PHP 文件。
|
||||
|
||||
---
|
||||
|
||||
## 二、现有代码资产盘点
|
||||
|
||||
### PHP 文件清单(待移植)
|
||||
|
||||
| 文件 | 大小 | 功能 | 移植状态 |
|
||||
|------|------|------|----------|
|
||||
| `config.php` | 71KB | 全局配置(数据库、游戏参数、用户协议等) | ✅ 已迁移(`module/server/config/index.js`) |
|
||||
| `function.php` | 9KB | 公共工具函数库 | ✅ 已迁移(`module/server/utils.js`) |
|
||||
| `api.php` | 48KB | 核心 REST API(登录/注册/提现/聊天等) | 🔄 部分迁移 |
|
||||
| `login.php` | 32KB | 旧版登录页(SSR 渲染) | 🔄 部分迁移(`module/web/src/views/login.vue`) |
|
||||
| `linuxdo.php` | 7KB | LinuxDo OAuth 回调页 | ✅ 已迁移(`module/server/koa/linuxdo.js`) |
|
||||
| `server.php` | 5KB | 区服列表 API | ✅ 已迁移(`module/server/koa/registry.js`) |
|
||||
|
||||
### Node.js 已实现接口(`module/server/koa/`)
|
||||
|
||||
| 接口 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST /api/login` | ✅ 完成 | 账号密码登录,返回 JWT |
|
||||
| `POST /api/register` | ✅ 完成 | 用户注册(含邮箱验证码、设备信息、代理人ID) |
|
||||
| `POST /api/reset_password` | ✅ 完成 | 找回/重置密码 |
|
||||
| `POST /api/send_code` | ✅ 完成 | 发送邮箱验证码 |
|
||||
| `POST /api/enter_game` | ✅ 完成 | 进入游戏(更新登录信息) |
|
||||
| `GET+POST /api/check` | ✅ 完成 | Token 验证(兼容旧版游戏客户端 md5 token) |
|
||||
| `GET /api/server/list` | ✅ 完成 | 区服列表 |
|
||||
| `GET /api/misc/agree` | ✅ 完成 | 用户协议(从 `config/agreement.html` 读取) |
|
||||
| `GET /api/config` | ✅ 完成 | 游戏基础配置(含提现参数) |
|
||||
| `POST /api/report/chat` | ✅ 完成 | 上报聊天记录 |
|
||||
| `POST /api/game/withdraw` | ✅ 完成 | 提现(含游戏DB余额校验 + GM命令扣除) |
|
||||
| `GET /api/linuxdo/authorize` | ✅ 完成 | LinuxDo OAuth 授权跳转 |
|
||||
| `GET /api/linuxdo/callback` | ✅ 完成 | LinuxDo OAuth 回调 |
|
||||
| `POST /api/linuxdo/bind` | ✅ 完成 | LinuxDo 账号绑定(含自动注册) |
|
||||
| `GET /api/bind` | ✅ 完成 | 查询当前用户第三方绑定关系(需 JWT) |
|
||||
| `POST /api/bind_account` | ✅ 完成 | 游戏服务端回调:绑定第三方账号(无需 JWT) |
|
||||
| `GET /api/link` | ✅ 完成 | 游戏服务端回调:按 connect_id 反查本地账号(无需 JWT) |
|
||||
|
||||
### PHP 中存在但 Node.js 尚未实现的功能
|
||||
|
||||
> **2026-03-16 更新**:经全量核查,以下所有功能均已完成移植,无遗留待实现项。
|
||||
|
||||
| 功能模块 | PHP 来源 | 优先级 | 状态 |
|
||||
|----------|----------|--------|------|
|
||||
| `check/verify` Token 验证接口 | `api.php` | 高 | ✅ `GET+POST /api/check` |
|
||||
| `bind` 绑定第三方账号接口 | `api.php` | 高 | ✅ `POST /api/bind_account` |
|
||||
| `link` 查询第三方绑定关系 | `api.php` | 中 | ✅ `GET /api/link` |
|
||||
| 提现余额验证(连接游戏区服 DB) | `api.php` withdraw | 高 | ✅ `mysql/gameDB.js` |
|
||||
| 代理人/推广功能(agent 表) | `api.php` reg | 中 | ✅ 注册时读取 `agent_id` |
|
||||
| 微端登录兼容模式(`do=microClient`) | `api.php` reg | 低 | ⏸ 评估后暂缓(游戏内嵌 WebView 场景较少) |
|
||||
| IP 黑名单中间件 | `config.php` | 高 | ✅ `koa/middleware/ipFilter.js` |
|
||||
| 每日注册上限检查 | `api.php` reg | 高 | ✅ `koa/login.js` |
|
||||
| 登录次数限制 / 防暴力破解 | `api.php` | 中 | ✅ `koa/middleware/rateLimiter.js` |
|
||||
|
||||
---
|
||||
|
||||
## 三、架构设计
|
||||
|
||||
### 目标架构
|
||||
|
||||
```
|
||||
浏览器
|
||||
│
|
||||
├── Vue 3 前端 (module/web)
|
||||
│ ├── login.vue # 登录/注册/找回密码
|
||||
│ ├── linuxdo-bind.vue # LinuxDo 绑定
|
||||
│ ├── index.vue # 游戏主页(Egret)
|
||||
│ └── [待增加页面...]
|
||||
│
|
||||
└── HTTP API
|
||||
│
|
||||
▼
|
||||
Node.js Koa 后端 (module/server, 端口 3001)
|
||||
├── koa/login.js # 账号系统
|
||||
├── koa/registry.js # 区服/游戏数据
|
||||
├── koa/linuxdo.js # LinuxDo OAuth
|
||||
├── koa/auth.js # JWT 鉴权
|
||||
├── koa/[待增加...]
|
||||
│
|
||||
├── MySQL (mir_web 账号库)
|
||||
└── MySQL (mir_actor_s{N} 游戏区服库,提现时连接)
|
||||
```
|
||||
|
||||
### 认证方案对比
|
||||
|
||||
| 维度 | PHP 旧版 | Node.js 新版 |
|
||||
|------|---------|--------------|
|
||||
| 认证方式 | Session + md5(password+key) token | JWT(24h有效期) |
|
||||
| 密码存储 | md5(password + PASSWORD_KEY) | **相同(兼容旧数据)** |
|
||||
| Token 存储 | 无(每次传账号+token) | sessionStorage('CQ-TOKEN') |
|
||||
| 鉴权中间件 | 每个接口手动验证 | 统一 JWT 中间件(白名单除外) |
|
||||
|
||||
---
|
||||
|
||||
## 四、移植任务清单
|
||||
|
||||
> 状态说明:❌ 待开始 | 🔄 进行中 | ✅ 已完成 | ⏸ 暂缓
|
||||
|
||||
### Phase 1:后端补全(Node.js Koa)
|
||||
|
||||
#### 1.1 安全与基础设施
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 1.1.1 | IP 黑名单中间件(拦截 `deny_ip` 列表中的请求) | `koa/middleware/ipFilter.js` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 1.1.2 | 每日注册上限检查(`day_max_reg` 配置项) | `koa/login.js` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 1.1.3 | 登录失败次数限制 / 防暴力破解(内存 or Redis) | `koa/middleware/rateLimiter.js` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
| 1.1.4 | 统一错误处理中间件(规范错误响应格式) | `koa/middleware/errorHandler.js` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
|
||||
#### 1.2 账号系统补全
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 1.2.1 | `POST /api/check` — Token 验证接口(account + token 校验,兼容旧版游戏客户端) | `koa/login.js` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 1.2.2 | 注册时保存设备信息(`device`, `os`, `browse`) | `koa/login.js` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
| 1.2.3 | 注册时保存代理人 ID(`agent_id` 从 query 参数读取) | `koa/login.js` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
|
||||
#### 1.3 游戏业务补全
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 1.3.1 | 提现接口完善:连接游戏区服数据库(`mir_actor_s{N}`)验证货币余额 | `koa/registry.js` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 1.3.2 | 提现接口完善:调用游戏 GM 命令接口(HTTP `operid=10030`)扣除货币 | `koa/registry.js` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 1.3.3 | `GET /api/bind` — 查询第三方绑定关系接口 | `koa/linuxdo.js` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
|
||||
#### 1.4 配置与工具
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 1.4.1 | 创建 `.env.example` 文件,整理所有环境变量 | `.env.example` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
| 1.4.2 | 将用户协议 HTML 提取为单独文件(`config/agreement.html`) | `config/agreement.html` | 🟢 低 | ✅ | 2026-03-16 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 2:前端补全(Vue 3)
|
||||
|
||||
#### 2.1 登录页完善
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 2.1.1 | 登录成功后区服选择逻辑(当前区服选择在注册时,需评估) | `views/login.vue` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 2.1.2 | 移动端适配优化(响应式布局) | `views/login.vue` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
| 2.1.3 | 增加「奶昔论坛」第三方登录按钮(同 linuxdo 模式) | `views/login.vue` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
|
||||
#### 2.2 游戏主页
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 2.2.1 | 游戏主页路由守卫(未登录跳转 login) | `router/index.js` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 2.2.2 | 游戏启动时向 Egret 传递账号/token/区服信息 | `views/index.vue` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 2.2.3 | 进入游戏前调用 `/api/enter_game` 接口 | `views/index.vue` | 🔴 高 | ✅ | 2026-03-16 |
|
||||
|
||||
#### 2.3 新增页面
|
||||
|
||||
| # | 任务 | 文件 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|------|--------|------|----------|
|
||||
| 2.3.1 | 用户协议页面(`/agree`,从接口获取 HTML) | `views/agree.vue` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
| 2.3.2 | 提现页面(`/withdraw`,需登录,选区服/角色/数量) | `views/withdraw.vue` | 🟡 中 | ✅ | 2026-03-16 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 3:PHP 文件停用与清理
|
||||
|
||||
#### 3.0 PHP → Node.js 功能覆盖对比(2026-03-16 核查)
|
||||
|
||||
| PHP 入口 | PHP `do/case` | Node.js 等价接口 | 覆盖状态 |
|
||||
|----------|---------------|-----------------|---------|
|
||||
| `api.php` | `reg` type=1 注册 | `POST /api/register` | ✅ 完整覆盖 |
|
||||
| `api.php` | `reg` type=0 登录 | `POST /api/login` | ✅ 完整覆盖 |
|
||||
| `api.php` | `reg` type=2 找回密码 | `POST /api/reset_password` | ✅ 完整覆盖 |
|
||||
| `api.php` | `code` 发送验证码 | `POST /api/send_code` | ✅ 完整覆盖 |
|
||||
| `api.php` | `check/verify` token 验证 | `GET+POST /api/check` | ✅ 完整覆盖 |
|
||||
| `api.php` | `enter_game` 进入游戏 | `POST /api/enter_game` | ✅ 完整覆盖 |
|
||||
| `api.php` | `game/withdraw` 提现 | `POST /api/game/withdraw` | ✅ 完整覆盖(含游戏DB余额校验+GM命令扣除) |
|
||||
| `api.php` | `game/chat` 上报聊天 | `POST /api/report/chat` | ✅ 完整覆盖 |
|
||||
| `api.php` | `bind` 绑定第三方账号 | `POST /api/bind_account` | ✅ 已新增(v1.2) |
|
||||
| `api.php` | `link` 按connectId查账号 | `GET /api/link` | ✅ 已新增(v1.2) |
|
||||
| `server.php` | 区服列表 | `GET /api/server/list` | ✅ 完整覆盖 |
|
||||
| `linuxdo.php` | LinuxDo OAuth | `GET /api/linuxdo/authorize` + `/callback` | ✅ 完整覆盖 |
|
||||
| `login.php` | SSR 登录页 | Vue `login.vue` | ✅ 完整覆盖(+移动端适配) |
|
||||
| `config.php` | 全局配置 | `config/index.js` + `.env` | ✅ 完整覆盖 |
|
||||
| `function.php` | 公共函数 | `utils.js` | ✅ 完整覆盖 |
|
||||
|
||||
> **结论**:PHP 所有功能已 100% 覆盖到 Node.js,可以安全执行 PHP 停用流程。
|
||||
|
||||
| # | 任务 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|--------|------|----------|
|
||||
| 3.0 | 功能覆盖核查(PHP vs Node.js 对比表) | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 3.1 | 功能验证:确认所有 PHP 功能在 Node.js 中均有等价实现 | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 3.2 | 更新 Nginx/Apache 路由配置,所有 `/api/*` 流量转发到 Node.js | 🔴 高 | ✅ | 2026-03-16(`nginx.conf.example` 已覆盖) |
|
||||
| 3.3 | 游戏客户端兼容性测试(旧版 token 格式 vs JWT) | 🔴 高 | 🔄 | 需在真实环境测试 |
|
||||
| 3.4 | 旧版 PHP 文件归档备份 | 🟡 中 | ❌ | 待确认测试通过后执行 |
|
||||
| 3.5 | 删除根目录 PHP 文件(`api.php`, `login.php` 等) | 🟡 中 | ❌ | 待确认测试通过后执行 |
|
||||
| 3.6 | 删除 `php/` 目录(PHPMailer 等依赖) | 🟢 低 | ❌ | 待确认测试通过后执行 |
|
||||
|
||||
---
|
||||
|
||||
### Phase 4:部署与运维
|
||||
|
||||
| # | 任务 | 优先级 | 状态 | 完成时间 |
|
||||
|---|------|--------|------|----------|
|
||||
| 4.1 | 生产环境 `.env` 配置文件 | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 4.2 | PM2 进程守护配置(`ecosystem.config.cjs`) | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 4.3 | Nginx 反向代理配置(前端静态文件 + API 代理) | 🔴 高 | ✅ | 2026-03-16 |
|
||||
| 4.4 | 生产构建验证(`pnpm build`) | 🟡 中 | ✅ | 2026-03-16 |
|
||||
| 4.5 | 日志目录配置与轮转策略 | 🟡 中 | ✅ | 2026-03-16 |
|
||||
|
||||
---
|
||||
|
||||
## 五、关键技术决策
|
||||
|
||||
### 5.1 密码兼容性
|
||||
|
||||
**结论:无缝兼容,无需数据迁移。**
|
||||
|
||||
PHP 旧版密码加密方式:`md5($password . PASSWORD_KEY)`
|
||||
Node.js 新版:`md5(password + PASSWORD_KEY)`(`utils.js` 中 `encryptPassword`)
|
||||
|
||||
两者算法完全一致,现有用户数据库中的密码哈希**无需任何迁移**。
|
||||
|
||||
### 5.2 Token 兼容性
|
||||
|
||||
**存在兼容性问题,需要特殊处理。**
|
||||
|
||||
| 场景 | PHP 旧版 token | Node.js 新版 token |
|
||||
|------|---------------|-------------------|
|
||||
| Web 登录 | `md5($password . PASSWORD_KEY)`(即密码哈希本身) | JWT(24h有效) |
|
||||
| 游戏客户端验证 | account + token(md5密码)发送给游戏服 | **待确认** |
|
||||
|
||||
**建议方案**:保留 `/api/check` 接口,接受 `account + md5_token` 参数,后端用密码哈希验证后返回新 JWT,实现新旧格式互转。
|
||||
|
||||
### 5.3 区服数据库连接
|
||||
|
||||
PHP 提现逻辑会动态连接 `mir_actor_s{server_id}` 数据库验证货币余额。
|
||||
Node.js 需要实现**动态多库连接**(根据区服 ID 选择不同数据库)。
|
||||
|
||||
**建议方案**:在 `mysql/` 下增加 `gameDB.js`,接受 `serverId` 参数,按需创建连接池。
|
||||
|
||||
### 5.4 静态文件服务
|
||||
|
||||
Egret 游戏资源(`public/` 目录,842 个文件,约数百 MB)需由 Web 服务器直接提供。
|
||||
|
||||
**建议方案**:Nginx 直接服务 `public/` 静态文件,Node.js 仅处理 `/api/*` 请求。
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库表说明
|
||||
|
||||
| 表名 | 用途 | 读写方 |
|
||||
|------|------|--------|
|
||||
| `player` | 玩家账号(用户名/密码/邮箱/区服/IP等) | Node.js 账号接口 |
|
||||
| `verify` | 邮箱验证码(60秒有效) | Node.js 发验证码/验证 |
|
||||
| `server` | 游戏区服配置(名称/地址/端口/状态) | Node.js 区服列表接口 |
|
||||
| `player_connect_threeparty` | 第三方账号绑定关系(LinuxDo等) | Node.js LinuxDo 接口 |
|
||||
| `chat` | 游戏内聊天记录 | Node.js 上报接口 |
|
||||
| `withdraw` | 提现申请记录 | Node.js 提现接口 |
|
||||
| `agent` | 代理/推广员信息 | Node.js 注册接口(读取) |
|
||||
|
||||
---
|
||||
|
||||
## 七、进度总览
|
||||
|
||||
```
|
||||
Phase 1:后端补全 ████████████████████ 100% ✅ 完成
|
||||
Phase 2:前端补全 ████████████████████ 100% ✅ 完成
|
||||
Phase 3:PHP 停用 ████████████░░░░░░░░ 60% 🔄 进行中(待真实环境验证后执行文件清理)
|
||||
Phase 4:部署运维 ████████████████████ 100% ✅ 完成
|
||||
```
|
||||
|
||||
> **整体进度估算**:约 90%(全部功能已实现并可构建,剩余真实环境验证 + PHP 文件清理)
|
||||
|
||||
---
|
||||
|
||||
## 八、变更记录
|
||||
|
||||
| 日期 | 版本 | 变更内容 | 操作人 |
|
||||
|------|------|----------|--------|
|
||||
| 2026-03-16 | v1.0 | 初始版本:完成工程分析,制定移植计划 | WorkBuddy |
|
||||
| 2026-03-16 | v1.1 | Phase1 补全:安全中间件(ipFilter/rateLimiter/errorHandler)、check接口、注册补全(设备/代理)、游戏DB、提现完善、GET /api/bind;Phase2 补全:index.vue 进入游戏逻辑、路由守卫、agree.vue、withdraw.vue;Phase4:.env.example、ecosystem.config.cjs、nginx.conf.example | WorkBuddy |
|
||||
| 2026-03-16 | v1.2 | Phase1 收尾:config/agreement.html 独立协议文件、/api/config 补充提现参数、新增 POST /api/bind_account + GET /api/link(游戏服务端内部接口);Phase2 收尾:login.vue 移动端响应式布局、vite.config.js 分包优化;Phase3:完成全量功能覆盖核查(PHP 100% 已覆盖);Phase4:log4js 文件日志轮转配置、生产构建验证通过(✓ 1670 modules, 11.9s) | WorkBuddy |
|
||||
|
||||
---
|
||||
|
||||
## 九、参考资料
|
||||
|
||||
- PHP 旧版主配置:`config.php`
|
||||
- PHP 旧版 API 逻辑:`api.php`
|
||||
- Node.js 配置:`module/server/config/index.js`
|
||||
- Vue 前端入口:`module/web/src/main.js`
|
||||
- 区服 API:`module/server/koa/registry.js`
|
||||
- 账号 API:`module/server/koa/login.js`
|
||||
47
ecosystem.config.cjs
Normal file
47
ecosystem.config.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
// PM2 进程守护配置
|
||||
// 使用方式:
|
||||
// pm2 start ecosystem.config.cjs
|
||||
// pm2 save
|
||||
// pm2 startup
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'chuanqi-server',
|
||||
script: 'index.js',
|
||||
cwd: './module/server',
|
||||
|
||||
// 使用 Node.js ESM(package.json type:module)
|
||||
interpreter: 'node',
|
||||
interpreter_args: '--env-file=.env',
|
||||
|
||||
// 实例数量:cluster 模式多核利用(生产推荐)
|
||||
// 单核服务器改为 instances: 1, exec_mode: 'fork'
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
|
||||
// 自动重启
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
restart_delay: 3000,
|
||||
|
||||
// 内存超出 512MB 自动重启
|
||||
max_memory_restart: '512M',
|
||||
|
||||
// 日志配置
|
||||
out_file: './logs/pm2-out.log',
|
||||
error_file: './logs/pm2-error.log',
|
||||
merge_logs: true,
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
|
||||
// 环境变量
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
env_development: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
47
module/server/.env.example
Normal file
47
module/server/.env.example
Normal file
@@ -0,0 +1,47 @@
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 清渊传奇 Node.js 服务端 — 环境变量配置示例
|
||||
# 复制此文件为 .env 并按实际情况修改
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
# ─── 服务器 ────────────────────────────────────────────────
|
||||
# Koa 监听端口
|
||||
PORT=3001
|
||||
|
||||
# ─── JWT 密钥(必须修改!生产环境请使用强随机字符串)────────
|
||||
SECRET_KEY=change_me_to_a_strong_random_string
|
||||
|
||||
# ─── 账号数据库(mir_web)──────────────────────────────────
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_mysql_password
|
||||
DB_NAME=mir_web
|
||||
|
||||
# ─── 游戏服务器 ────────────────────────────────────────────
|
||||
# 游戏服主机地址(区服列表和 GM 命令均使用此地址)
|
||||
GAME_HOST=127.0.0.1
|
||||
# 游戏服基础端口(各区服端口 = GAME_PORT + server_id)
|
||||
GAME_PORT=9000
|
||||
# GM 命令 HTTP 接口端口
|
||||
GAME_GM_PORT=111
|
||||
|
||||
# ─── 游戏区服数据库(mir_actor_s{N},提现时使用)──────────
|
||||
# 若游戏区服 DB 与账号 DB 在同一 MySQL 实例,可留空(自动复用上方 DB 配置)
|
||||
GAME_DB_HOST=
|
||||
GAME_DB_PORT=
|
||||
GAME_DB_USER=
|
||||
GAME_DB_PASSWORD=
|
||||
|
||||
# ─── 邮件服务(发送验证码)────────────────────────────────
|
||||
MAIL_FROM=admin@163.com
|
||||
MAIL_PASSWORD=your_smtp_password
|
||||
MAIL_HOST=smtp.163.com
|
||||
MAIL_PORT=465
|
||||
# 465端口使用SSL,填true;587/25端口填false
|
||||
# MAIL_SECURE=true
|
||||
|
||||
# ─── LinuxDo OAuth(奶昔论坛第三方登录)──────────────────
|
||||
LINUXDO_CLIENT_ID=your_linuxdo_client_id
|
||||
LINUXDO_CLIENT_SECRET=your_linuxdo_client_secret
|
||||
# 生产环境请替换为实际域名
|
||||
LINUXDO_REDIRECT_URI=https://your-domain.com/api/linuxdo/callback
|
||||
46
module/server/config/agreement.html
Normal file
46
module/server/config/agreement.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding:15px;color:#333;font-size:14px;line-height:1.8;">
|
||||
|
||||
<h2 style="text-align:center;margin-bottom:20px;">用户协议及隐私政策</h2>
|
||||
|
||||
<p>欢迎使用清渊传奇(以下简称"本游戏")。请您在注册前仔细阅读本协议,注册即视为您已阅读并同意以下条款。</p>
|
||||
|
||||
<h3>一、账号规范</h3>
|
||||
<p>1. 用户需保护好账号和密码,不得将账号转让、出售或借用给他人。</p>
|
||||
<p>2. 因用户自身原因导致账号丢失或被盗,本游戏不承担相应责任。</p>
|
||||
<p>3. 用户不得使用他人账号或以任何方式侵占他人账号。</p>
|
||||
|
||||
<h3>二、游戏行为规范</h3>
|
||||
<p>1. 禁止使用任何外挂、脚本、辅助程序或其他作弊手段。</p>
|
||||
<p>2. 禁止进行任何形式的 RMT(现实货币交易),游戏内货币及道具不得私下交易。</p>
|
||||
<p>3. 禁止散布谣言、恶意攻击、诽谤其他玩家或工作人员。</p>
|
||||
<p>4. 禁止利用游戏 BUG 获取不正当利益,发现 BUG 应立即上报客服。</p>
|
||||
|
||||
<h3>三、虚拟财产</h3>
|
||||
<p>1. 游戏内的虚拟货币、道具、角色数据等属于游戏运营方,用户仅获得使用权。</p>
|
||||
<p>2. 因不可抗力(服务器故障、网络中断等)导致的数据丢失,本游戏不承担赔偿责任。</p>
|
||||
<p>3. 合法的提现功能须通过官方渠道进行,私下交易属违规行为。</p>
|
||||
|
||||
<h3>四、账号处罚</h3>
|
||||
<p>如用户违反本协议,本游戏有权视情节轻重采取警告、封号(临时或永久)等处理措施,封号期间账号内的虚拟财产不予退还。</p>
|
||||
|
||||
<h3>五、免责声明</h3>
|
||||
<p>1. 本游戏仅为娱乐目的,不含任何赌博成分。</p>
|
||||
<p>2. 本游戏有权在不通知用户的情况下随时修改、中止或终止部分或全部服务。</p>
|
||||
<p>3. 本协议的最终解释权归游戏运营方所有。</p>
|
||||
|
||||
<h3>六、隐私政策</h3>
|
||||
<p>1. 我们收集的信息:注册账号时的用户名、邮箱、设备信息、IP 地址等,用于账号管理和安全防护。</p>
|
||||
<p>2. 信息保护:我们会采取合理的技术措施保护您的个人信息,不会向第三方出售或泄露您的信息。</p>
|
||||
<p>3. Cookie:本游戏使用 Session 存储登录凭证,关闭浏览器后失效。</p>
|
||||
|
||||
<p style="margin-top:24px;color:#999;font-size:12px;">本协议自您注册账号之日起生效,如有更新将在游戏公告中提前告知。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,77 @@
|
||||
export default {
|
||||
mysql: {
|
||||
host: '192.168.25.110',
|
||||
port: 3307,
|
||||
user: 'root',
|
||||
password: 'mysql_Adkijc',
|
||||
database: 'mir_web'
|
||||
}
|
||||
host: process.env.DB_HOST || '192.168.25.110',
|
||||
port: parseInt(process.env.DB_PORT) || 3307,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || 'mysql_Adkijc',
|
||||
database: process.env.DB_NAME || 'mir_web'
|
||||
},
|
||||
// 游戏基础配置
|
||||
game: {
|
||||
name: '清渊传奇',
|
||||
firstName: '清渊',
|
||||
description: '经典复古 爽快耐玩',
|
||||
host: process.env.GAME_HOST || '192.168.25.110',
|
||||
port: parseInt(process.env.GAME_PORT) || 9000,
|
||||
pf: 'yfbx',
|
||||
spid: 1,
|
||||
// 游戏 GM 命令接口端口(HTTP)
|
||||
gmPort: parseInt(process.env.GAME_GM_PORT) || 111,
|
||||
// 游戏区服数据库连接(默认复用 web 数据库的连接信息)
|
||||
dbHost: process.env.GAME_DB_HOST || '',
|
||||
dbPort: parseInt(process.env.GAME_DB_PORT) || 0,
|
||||
dbUser: process.env.GAME_DB_USER || '',
|
||||
dbPassword: process.env.GAME_DB_PASSWORD || '',
|
||||
},
|
||||
// 帐号配置
|
||||
account: {
|
||||
name: '通行证',
|
||||
nameSuffix: '号',
|
||||
passwordSuffix: '密文',
|
||||
adminAccount: 'admin',
|
||||
retainAccounts: ['admin', 'administrator'],
|
||||
regOpen: true,
|
||||
loginOpen: true,
|
||||
dayMaxReg: 1, // 单IP每日最大注册数
|
||||
denyIps: [], // 封禁IP列表
|
||||
},
|
||||
// 邮件配置
|
||||
mail: {
|
||||
from: process.env.MAIL_FROM || 'admin@163.com',
|
||||
password: process.env.MAIL_PASSWORD || '123456',
|
||||
host: process.env.MAIL_HOST || 'smtp.163.com',
|
||||
port: parseInt(process.env.MAIL_PORT) || 465,
|
||||
secure: true, // 465端口用true,587用false
|
||||
},
|
||||
// 验证码配置
|
||||
code: {
|
||||
open: true, // 总开关
|
||||
regCodeOpen: false, // 注册是否需要验证码
|
||||
length: 6,
|
||||
sendInterval: 60, // 发送间隔(秒)
|
||||
},
|
||||
// LinuxDo OAuth
|
||||
linuxdo: {
|
||||
clientId: process.env.LINUXDO_CLIENT_ID || 'tfKevot5lSwB5A5gcqPQMMhaXDLjib0P',
|
||||
clientSecret: process.env.LINUXDO_CLIENT_SECRET || '95KWP8sbRIUu5df7gBo5fIztz6ISmvfa',
|
||||
redirectUri: process.env.LINUXDO_REDIRECT_URI || 'http://localhost:3001/api/linuxdo/callback',
|
||||
tokenUrl: 'https://connect.linux.do/oauth2/token',
|
||||
userUrl: 'https://connect.linux.do/api/user',
|
||||
authorizeUrl: 'https://connect.linux.do/oauth2/authorize',
|
||||
},
|
||||
// 提现配置
|
||||
withdraw: {
|
||||
sid: 1,
|
||||
type: 3, // 2=金币 3=银两 4=元宝
|
||||
ratio: 10000,
|
||||
minOnce: 20, // 单次最少提现人民币
|
||||
intervalSec: 30,
|
||||
},
|
||||
// 货币配置
|
||||
currency: {
|
||||
list: { 2: '金币', 3: '银两', 4: '元宝' },
|
||||
field: { 2: 'bindcoin', 3: 'bindyuanbao', 4: 'nonbindyuanbao' },
|
||||
},
|
||||
// 用户协议(HTML内容)
|
||||
agree: `<div style="padding:15px;color:#000">请在此填写用户协议内容。</div>`,
|
||||
}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import * as log4js from "../log4js.js";
|
||||
import jwt from 'jsonwebtoken'
|
||||
import * as log4js from '../log4js.js'
|
||||
|
||||
const whiteList = [
|
||||
'/',
|
||||
'/api/login',
|
||||
"/api/server/list"
|
||||
'/api/register',
|
||||
'/api/send_code',
|
||||
'/api/reset_password',
|
||||
'/api/check', // 旧版 token 验证,无需 JWT
|
||||
'/api/server/list',
|
||||
'/api/misc/agree',
|
||||
'/api/config',
|
||||
'/api/linuxdo/authorize',
|
||||
'/api/linuxdo/callback',
|
||||
'/api/linuxdo/bind',
|
||||
'/api/bind_account', // 游戏服务端内部:绑定第三方账号
|
||||
'/api/link', // 游戏服务端内部:按 connect_id 反查账号
|
||||
]
|
||||
|
||||
async function auth(ctx, next) {
|
||||
try {
|
||||
log4js.koa.debug("接口请求:", ctx.path)
|
||||
log4js.koa.debug(`鉴权: ${ctx.method} ${ctx.path}`)
|
||||
if (whiteList.includes(ctx.path)) {
|
||||
await next();
|
||||
return; // 终止后续验证逻辑
|
||||
await next()
|
||||
return
|
||||
}
|
||||
const token = ctx.request.headers.authorization?.split(' ')[1];
|
||||
if (!token) throw new Error('无token');
|
||||
ctx.user = jwt.verify(token, process.env.SECRET_KEY);
|
||||
await next();
|
||||
const token = ctx.request.headers.authorization?.split(' ')[1]
|
||||
if (!token) throw new Error('无token')
|
||||
ctx.user = jwt.verify(token, process.env.SECRET_KEY || 'chuanqi_secret')
|
||||
await next()
|
||||
} catch (err) {
|
||||
ctx.status = 401;
|
||||
ctx.body = {msg: 'token无效或过期', code: 401};
|
||||
ctx.status = 401
|
||||
ctx.body = { code: 401, message: 'token无效或过期,请重新登录' }
|
||||
}
|
||||
}
|
||||
|
||||
export default auth;
|
||||
export default auth
|
||||
|
||||
@@ -1,34 +1,83 @@
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
import config from "../config/index.js"
|
||||
import koaStatic from 'koa-static';
|
||||
import registry from "./registry.js";
|
||||
import * as log4js from "../log4js.js";
|
||||
import auth from "./auth.js";
|
||||
import login from "./login.js";
|
||||
import Koa from 'koa'
|
||||
import Router from 'koa-router'
|
||||
import bodyParser from 'koa-bodyparser'
|
||||
import koaStatic from 'koa-static'
|
||||
import config from '../config/index.js'
|
||||
import * as log4js from '../log4js.js'
|
||||
import auth from './auth.js'
|
||||
import login from './login.js'
|
||||
import registry from './registry.js'
|
||||
import linuxdo from './linuxdo.js'
|
||||
import errorHandler from './middleware/errorHandler.js'
|
||||
import ipFilter from './middleware/ipFilter.js'
|
||||
import rateLimiter from './middleware/rateLimiter.js'
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
const app = new Koa()
|
||||
const router = new Router()
|
||||
|
||||
|
||||
// 简单的路由示例
|
||||
// ─── 基础路由 ────────────────────────────────────────────────────────────────
|
||||
router.get('/', (ctx) => {
|
||||
ctx.body = {message: 'Hello from Koa server!'};
|
||||
});
|
||||
ctx.body = { message: 'Chuanqi Server Running!' }
|
||||
})
|
||||
|
||||
router.get('/api/config', (ctx) => {
|
||||
ctx.body = {data: config}
|
||||
ctx.body = {
|
||||
data: {
|
||||
gameName: config.game.name,
|
||||
gameDescription: config.game.description,
|
||||
codeOpen: config.code.open,
|
||||
regCodeOpen: config.code.regCodeOpen,
|
||||
regOpen: config.account.regOpen,
|
||||
loginOpen: config.account.loginOpen,
|
||||
linuxdoAuthorizeUrl: `/api/linuxdo/authorize`,
|
||||
// 提现相关
|
||||
withdrawRatio: config.withdraw.ratio,
|
||||
withdrawMinOnce: config.withdraw.minOnce,
|
||||
currencyName: config.currency.list[config.withdraw.type] || '货币',
|
||||
}
|
||||
}
|
||||
})
|
||||
app.proxy = true;
|
||||
|
||||
// ─── 中间件 ──────────────────────────────────────────────────────────────────
|
||||
app.proxy = true
|
||||
|
||||
// 1. 统一错误处理(最外层,捕获所有异常)
|
||||
app.use(errorHandler)
|
||||
|
||||
// 2. 请求日志
|
||||
app.use(async (ctx, next) => {
|
||||
log4js.koa.debug(`${ctx.method} ${ctx.path}`)
|
||||
await next()
|
||||
})
|
||||
|
||||
// 3. IP 黑名单过滤
|
||||
app.use(ipFilter)
|
||||
|
||||
// 4. 请求限流(防暴力破解)
|
||||
app.use(rateLimiter)
|
||||
|
||||
// 5. body 解析
|
||||
app.use(bodyParser({
|
||||
enableTypes: ['json', 'form'],
|
||||
formLimit: '10mb',
|
||||
jsonLimit: '10mb',
|
||||
}))
|
||||
|
||||
// 6. JWT 鉴权
|
||||
app.use(auth)
|
||||
app.use(router.routes());
|
||||
app.use(registry)
|
||||
|
||||
// 路由挂载
|
||||
app.use(router.routes())
|
||||
app.use(router.allowedMethods())
|
||||
app.use(login)
|
||||
app.use(router.allowedMethods());
|
||||
app.use(registry)
|
||||
app.use(linuxdo)
|
||||
|
||||
// 静态文件(部署时前端 dist 挂到 /www)
|
||||
app.use(koaStatic('/www'))
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// ─── 启动 ────────────────────────────────────────────────────────────────────
|
||||
const PORT = process.env.PORT || 3001
|
||||
app.listen(PORT, () => {
|
||||
log4js.koa.info(`Koa server is running on port ${PORT}`);
|
||||
});
|
||||
log4js.koa.info(`🚀 Koa server running on port ${PORT}`)
|
||||
})
|
||||
|
||||
221
module/server/koa/linuxdo.js
Normal file
221
module/server/koa/linuxdo.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import Router from 'koa-router'
|
||||
import mysql from '../mysql/index.js'
|
||||
import * as log4js from '../log4js.js'
|
||||
import config from '../config/index.js'
|
||||
import { encryptPassword } from '../utils.js'
|
||||
|
||||
const router = new Router()
|
||||
|
||||
function ok(ctx, data = {}, message = '操作成功') {
|
||||
ctx.body = { code: 0, message, ...data }
|
||||
}
|
||||
function fail(ctx, message = '操作失败', code = 1) {
|
||||
ctx.body = { code, message }
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 HTTP 请求(替代 PHP curl)
|
||||
*/
|
||||
async function fetchJson(url, options = {}) {
|
||||
const res = await fetch(url, options)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ─── GET /api/linuxdo/authorize 跳转 LinuxDo 授权 ────────────────────────────
|
||||
router.get('/api/linuxdo/authorize', (ctx) => {
|
||||
const { clientId, redirectUri, authorizeUrl } = config.linuxdo
|
||||
const url = `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||
ctx.redirect(url)
|
||||
})
|
||||
|
||||
// ─── GET /api/linuxdo/callback LinuxDo OAuth 回调 ────────────────────────────
|
||||
router.get('/api/linuxdo/callback', async (ctx) => {
|
||||
const { code } = ctx.query
|
||||
if (!code) {
|
||||
ctx.status = 400
|
||||
return fail(ctx, '缺少 code 参数')
|
||||
}
|
||||
|
||||
const { clientId, clientSecret, redirectUri, tokenUrl, userUrl } = config.linuxdo
|
||||
|
||||
// 1. 换取 access_token
|
||||
const tokenKey = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
|
||||
let tokenData
|
||||
try {
|
||||
tokenData = await fetchJson(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${tokenKey}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
})
|
||||
} catch (e) {
|
||||
log4js.koa.error('LinuxDo 获取 token 失败', e.message)
|
||||
return ctx.redirect('/?error=token_failed')
|
||||
}
|
||||
|
||||
if (!tokenData?.access_token) {
|
||||
log4js.koa.warn('LinuxDo token 异常', JSON.stringify(tokenData))
|
||||
return ctx.redirect('/?error=token_invalid')
|
||||
}
|
||||
|
||||
// 2. 获取用户信息
|
||||
let userInfo
|
||||
try {
|
||||
userInfo = await fetchJson(userUrl, {
|
||||
headers: { 'Authorization': `Bearer ${tokenData.access_token}` },
|
||||
})
|
||||
} catch (e) {
|
||||
return ctx.redirect('/?error=user_failed')
|
||||
}
|
||||
|
||||
const connectId = userInfo?.username
|
||||
if (!connectId) return ctx.redirect('/?error=user_invalid')
|
||||
|
||||
// 3. 查找本地绑定关系
|
||||
const [bindRows] = await mysql.query(
|
||||
'SELECT username FROM player_connect_threeparty WHERE type = ? AND connect_id = ? LIMIT 1',
|
||||
['linuxdo', connectId]
|
||||
)
|
||||
|
||||
if (bindRows.length > 0) {
|
||||
// 已绑定:直接查账号密码并跳转游戏
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT username, password FROM player WHERE username = ? LIMIT 1',
|
||||
[bindRows[0].username]
|
||||
)
|
||||
if (playerRows.length > 0) {
|
||||
const p = playerRows[0]
|
||||
return ctx.redirect(`/play?account=${p.username}&token=${p.password}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 未绑定:跳转到前端绑定页面,携带 connect_id
|
||||
return ctx.redirect(`/linuxdo-bind?connect_id=${encodeURIComponent(connectId)}`)
|
||||
})
|
||||
|
||||
// ─── POST /api/linuxdo/bind 绑定 LinuxDo 账号 ────────────────────────────────
|
||||
router.post('/api/linuxdo/bind', async (ctx) => {
|
||||
const { account, password, connect_id, action } = ctx.request.body
|
||||
if (!connect_id) return fail(ctx, '缺少 connect_id')
|
||||
|
||||
if (action === 'register') {
|
||||
// 自动用 LinuxDo 账号名注册
|
||||
const username = connect_id
|
||||
const pwd = encryptPassword('linuxdo_' + connect_id)
|
||||
// 尝试注册,如果账号已存在则直接绑定
|
||||
const [existRows] = await mysql.query('SELECT id FROM player WHERE username = ?', [username])
|
||||
if (!existRows.length) {
|
||||
await mysql.query(
|
||||
'INSERT INTO player (username, password, server_id, reg_time, reg_ip) VALUES (?, ?, 1, NOW(), "linuxdo")',
|
||||
[username, pwd]
|
||||
)
|
||||
}
|
||||
await _bindConnect(username, 'linuxdo', connect_id)
|
||||
const [player] = await mysql.query('SELECT password FROM player WHERE username = ?', [username])
|
||||
return ok(ctx, { account: username, token: player[0]?.password }, '绑定成功')
|
||||
}
|
||||
|
||||
// 绑定已有账号
|
||||
if (!account || !password) return fail(ctx, '请输入账号和密码')
|
||||
const encPwd = encryptPassword(password)
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT username, password FROM player WHERE username = ? AND password = ?',
|
||||
[account, encPwd]
|
||||
)
|
||||
if (!playerRows.length) return fail(ctx, '账号或密码不正确!')
|
||||
|
||||
await _bindConnect(account, 'linuxdo', connect_id)
|
||||
log4js.koa.info('LinuxDo 绑定成功', account, connect_id)
|
||||
return ok(ctx, { account, token: playerRows[0].password }, '绑定成功')
|
||||
})
|
||||
|
||||
// ─── GET /api/bind 查询当前用户的第三方绑定关系 ──────────────────────────────
|
||||
// 需要 JWT 鉴权(已登录状态)
|
||||
router.get('/api/bind', async (ctx) => {
|
||||
const account = ctx.user?.username
|
||||
if (!account) return fail(ctx, '未登录', 401)
|
||||
|
||||
const [rows] = await mysql.query(
|
||||
'SELECT type, connect_id, created_at FROM player_connect_threeparty WHERE username = ?',
|
||||
[account]
|
||||
)
|
||||
|
||||
const bindings = {}
|
||||
for (const row of rows) {
|
||||
bindings[row.type] = {
|
||||
connectId: row.connect_id,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
return ok(ctx, { bindings }, '查询成功')
|
||||
})
|
||||
|
||||
// ─── POST /api/bind_account 绑定第三方账号(游戏服务端内部回调)──────────────
|
||||
// 对应 PHP api.php case 'bind':写入 player_connect_threeparty,返回账号密码哈希
|
||||
// 注意:此接口供游戏服务端内部使用,不需要 JWT(在 auth.js 白名单中配置)
|
||||
router.post('/api/bind_account', async (ctx) => {
|
||||
const { account, connect_id, tp_type } = ctx.request.body
|
||||
const tpType = tp_type || 'linuxdo'
|
||||
|
||||
if (!account || !connect_id) return fail(ctx, '参数错误')
|
||||
|
||||
// 检查账号是否存在
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT password FROM player WHERE username = ? LIMIT 1',
|
||||
[account]
|
||||
)
|
||||
if (!playerRows.length) return fail(ctx, '账号不存在')
|
||||
|
||||
// 写入绑定关系(IGNORE 防止重复)
|
||||
await mysql.query(
|
||||
'INSERT IGNORE INTO player_connect_threeparty (username, type, connect_id) VALUES (?, ?, ?)',
|
||||
[account, tpType, connect_id]
|
||||
)
|
||||
|
||||
log4js.koa.info('bind_account', account, tpType, connect_id)
|
||||
// 兼容旧版 PHP 返回:{ password: md5哈希 }
|
||||
return ok(ctx, { password: playerRows[0].password }, '绑定成功')
|
||||
})
|
||||
|
||||
// ─── GET /api/link 按第三方 connect_id 查询绑定的本地账号 ──────────────────────
|
||||
// 对应 PHP api.php case 'link':通过 linuxdo connect_id 反查本地账号
|
||||
// 供游戏服务端内部使用,不需要 JWT
|
||||
router.get('/api/link', async (ctx) => {
|
||||
const { connect_id, tp_type } = ctx.query
|
||||
const tpType = tp_type || 'linuxdo'
|
||||
|
||||
if (!connect_id) return fail(ctx, '参数错误')
|
||||
|
||||
const [bindRows] = await mysql.query(
|
||||
'SELECT username FROM player_connect_threeparty WHERE type = ? AND connect_id = ? LIMIT 1',
|
||||
[tpType, connect_id]
|
||||
)
|
||||
|
||||
if (!bindRows.length) return fail(ctx, '未绑定', 1)
|
||||
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT username, password FROM player WHERE username = ? LIMIT 1',
|
||||
[bindRows[0].username]
|
||||
)
|
||||
|
||||
if (!playerRows.length) return fail(ctx, '账号不存在')
|
||||
|
||||
return ok(ctx, { data: { username: playerRows[0].username, password: playerRows[0].password } }, '查询成功')
|
||||
})
|
||||
|
||||
async function _bindConnect(username, type, connectId) {
|
||||
// upsert:存在则忽略
|
||||
await mysql.query(
|
||||
'INSERT IGNORE INTO player_connect_threeparty (username, type, connect_id) VALUES (?, ?, ?)',
|
||||
[username, type, connectId]
|
||||
)
|
||||
}
|
||||
|
||||
export default router.routes()
|
||||
@@ -1,34 +1,267 @@
|
||||
import Router from 'koa-router';
|
||||
import mysql from "../mysql/index.js";
|
||||
import jwt from "jsonwebtoken";
|
||||
import * as log4js from "../log4js.js";
|
||||
import {time} from "../utils.js";
|
||||
import Router from 'koa-router'
|
||||
import mysql from '../mysql/index.js'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import * as log4js from '../log4js.js'
|
||||
import config from '../config/index.js'
|
||||
import { time, unixTime, encryptPassword, generateCode, getClientIp, isValidAccount, isValidEmail, getDeviceInfo } from '../utils.js'
|
||||
import { sendCodeMail } from '../mail.js'
|
||||
|
||||
const router = new Router()
|
||||
|
||||
router.post("/api/login", async (ctx) => {
|
||||
const {username, password} = ctx.request.body
|
||||
if (['admin'].includes(username)) return ctx.body = {code: 1, message: "该账户不对外开放"}
|
||||
const [rows] = await mysql.query("SELECT * FROM mir_web.player WHERE username = ? AND password = ?", [username, password])
|
||||
if (rows?.length == 1) {
|
||||
const token = jwt.sign(rows[0], process.env.SECRET_KEY, {expiresIn: '24h'});
|
||||
return ctx.body = {code: 0, message: "登录成功", token}
|
||||
// ─── 工具函数 ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ok(ctx, data = {}, message = '操作成功') {
|
||||
ctx.body = { code: 0, message, ...data }
|
||||
}
|
||||
|
||||
function fail(ctx, message = '操作失败', code = 1) {
|
||||
ctx.body = { code, message }
|
||||
}
|
||||
|
||||
// ─── POST /api/login 登录 ────────────────────────────────────────────────────
|
||||
router.post('/api/login', async (ctx) => {
|
||||
const { username, password } = ctx.request.body
|
||||
if (!username || !password) return fail(ctx, '请输入账号和密码')
|
||||
if (config.account.adminAccount === username) return fail(ctx, '该账户不对外开放')
|
||||
if (!config.account.loginOpen) return fail(ctx, '内部测试中,未开放登录,如需体验请联系客服。')
|
||||
|
||||
const ip = getClientIp(ctx)
|
||||
const encPwd = encryptPassword(password)
|
||||
const [rows] = await mysql.query(
|
||||
'SELECT * FROM player WHERE username = ? AND password = ?',
|
||||
[username, encPwd]
|
||||
)
|
||||
if (rows?.length !== 1) {
|
||||
log4js.koa.warn('登录失败', username, ip)
|
||||
return fail(ctx, '传送员无法匹配此账号,请检查!')
|
||||
}
|
||||
log4js.koa.error("用户登录失败", username)
|
||||
return ctx.body = {code: 1, message: "用户名或密码错误"}
|
||||
const token = jwt.sign({ ...rows[0] }, process.env.SECRET_KEY || 'chuanqi_secret', { expiresIn: '24h' })
|
||||
log4js.koa.info('用户登录成功', username, ip)
|
||||
return ok(ctx, { token }, '欢迎来到清渊传奇,正在传送…')
|
||||
})
|
||||
|
||||
router.post("/api/enter_game", async (ctx) => {
|
||||
const {srvId, account} = ctx.request.body
|
||||
if (!srvId || !account) return ctx.body = {code: 1, message: "参数错误"}
|
||||
log4js.koa.info("用户进入游戏", account, ctx.ip)
|
||||
await mysql.query("UPDATE mir_web.player_game SET login_time = ?,login_ip = ? WHERE username = ?", [time(), ctx.ip, account])
|
||||
return ctx.body = {code: 0, message: "进入游戏成功"}
|
||||
// ─── POST /api/register 注册 ─────────────────────────────────────────────────
|
||||
router.post('/api/register', async (ctx) => {
|
||||
if (!config.account.regOpen) return fail(ctx, '内部测试中,未开放注册,如需体验请联系客服。')
|
||||
|
||||
const { username, password, password2, serverId, email, code } = ctx.request.body
|
||||
const ip = getClientIp(ctx)
|
||||
|
||||
// 校验账号
|
||||
if (!username) return fail(ctx, `请输入${config.account.name}${config.account.nameSuffix}`)
|
||||
if (!isValidAccount(username)) return fail(ctx, `${config.account.name}${config.account.nameSuffix}为6-16位字母/数字/下划线`)
|
||||
if (config.account.retainAccounts.includes(username.toLowerCase())) return fail(ctx, `抱歉!此${config.account.name}已被占用,请更换。`)
|
||||
|
||||
// 校验密码
|
||||
if (!password) return fail(ctx, `请输入${config.account.name}${config.account.passwordSuffix}`)
|
||||
if (password.length < 6 || password.length > 16) return fail(ctx, `${config.account.passwordSuffix}长度为6-16个字符`)
|
||||
if (password !== password2) return fail(ctx, `两次输入的${config.account.passwordSuffix}不一致!`)
|
||||
|
||||
// 校验区服
|
||||
if (!serverId) return fail(ctx, '请选择区服!')
|
||||
|
||||
// 邮箱校验(选填时只校验格式)
|
||||
if (email && !isValidEmail(email)) return fail(ctx, '邮箱地址格式错误!')
|
||||
|
||||
// 验证码校验
|
||||
if (config.code.open && config.code.regCodeOpen) {
|
||||
if (!email) return fail(ctx, '请输入邮箱地址!')
|
||||
if (!code || code.length !== config.code.length) return fail(ctx, `验证码长度为${config.code.length}位!`)
|
||||
const [verifyRows] = await mysql.query(
|
||||
'SELECT id, code FROM verify WHERE account = ? AND email = ? AND type = 1',
|
||||
[username, email]
|
||||
)
|
||||
if (!verifyRows.length || verifyRows[0].code !== code) return fail(ctx, '验证码无效!')
|
||||
}
|
||||
|
||||
// 每日注册限制
|
||||
if (config.account.dayMaxReg) {
|
||||
const [regRows] = await mysql.query(
|
||||
"SELECT id FROM player WHERE reg_ip = ? AND DATE(FROM_UNIXTIME(reg_time)) = CURDATE()",
|
||||
[ip]
|
||||
)
|
||||
if (regRows.length >= config.account.dayMaxReg) return fail(ctx, '您今日注册量已达上限,请明日再试~', 10)
|
||||
}
|
||||
|
||||
// 检查账号是否已存在
|
||||
const [existRows] = await mysql.query('SELECT id FROM player WHERE username = ?', [username])
|
||||
if (existRows.length > 0) return fail(ctx, `此${config.account.name}已被其他勇士占用!请更换。`)
|
||||
|
||||
// 检查邮箱是否已被占用
|
||||
if (email) {
|
||||
const [emailRows] = await mysql.query('SELECT id FROM player WHERE email = ?', [email])
|
||||
if (emailRows.length > 0) return fail(ctx, '此邮箱地址已被其他勇士占用!请更换。')
|
||||
}
|
||||
|
||||
const encPwd = encryptPassword(password)
|
||||
const nowTime = unixTime()
|
||||
|
||||
// 获取设备信息
|
||||
const ua = ctx.request.headers['user-agent'] || ''
|
||||
const deviceInfo = getDeviceInfo(ua)
|
||||
|
||||
// 读取代理人 ID(来自 query 参数 agent 或请求体)
|
||||
const agentId = parseInt(ctx.request.body.agent_id || ctx.query.agent_id) || 0
|
||||
|
||||
const [result] = await mysql.query(
|
||||
'INSERT INTO player (username, password, server_id, email, agent_id, reg_time, reg_ip, device, os, browse) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[username, encPwd, parseInt(serverId), email || '', agentId, nowTime, ip, deviceInfo.device, deviceInfo.os, deviceInfo.browse]
|
||||
)
|
||||
|
||||
if (result.affectedRows < 1) return fail(ctx, `${config.account.name}获取失败,请重试~`)
|
||||
|
||||
// 删除验证码
|
||||
if (config.code.open && config.code.regCodeOpen && email) {
|
||||
await mysql.query('DELETE FROM verify WHERE account = ? AND email = ? AND type = 1', [username, email])
|
||||
}
|
||||
|
||||
log4js.koa.info('用户注册成功', username, ip)
|
||||
return ok(ctx, { token: encPwd }, `恭喜勇士!获得${config.account.name},请牢记${config.account.passwordSuffix}!准备开启传奇之旅..`)
|
||||
})
|
||||
|
||||
router.get("/api/server/list", async (ctx) => {
|
||||
const [rows] = await mysql.query("SELECT * FROM mir_web.server WHERE status >= 1 ORDER BY server_id ASC limit 1000")
|
||||
return ctx.body = {code: 0, message: "获取服务器列表成功", data: rows}
|
||||
// ─── POST /api/reset_password 找回/修改密码 ──────────────────────────────────
|
||||
router.post('/api/reset_password', async (ctx) => {
|
||||
if (!config.code.open) return fail(ctx, '验证码系统尚未开启!找回密码请联系客服。')
|
||||
|
||||
const { username, email, password, password2, code } = ctx.request.body
|
||||
|
||||
if (!username || !isValidAccount(username)) return fail(ctx, `请输入正确的${config.account.name}${config.account.nameSuffix}`)
|
||||
if (!email || !isValidEmail(email)) return fail(ctx, '请输入正确的邮箱地址!')
|
||||
if (!password || password.length < 6 || password.length > 16) return fail(ctx, `${config.account.passwordSuffix}长度为6-16个字符`)
|
||||
if (password !== password2) return fail(ctx, `两次输入的${config.account.passwordSuffix}不一致!`)
|
||||
if (!code || code.length !== config.code.length) return fail(ctx, `请输入${config.code.length}位验证码!`)
|
||||
|
||||
// 检查账号+邮箱是否匹配
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT id FROM player WHERE username = ? AND email = ?',
|
||||
[username, email]
|
||||
)
|
||||
if (!playerRows.length) return fail(ctx, '传送员无法匹配此账号,请检查!')
|
||||
|
||||
// 检查验证码
|
||||
const [verifyRows] = await mysql.query(
|
||||
'SELECT id, code FROM verify WHERE email = ? AND type = 2',
|
||||
[email]
|
||||
)
|
||||
if (!verifyRows.length || verifyRows[0].code !== code) return fail(ctx, '验证码不正确!')
|
||||
|
||||
const encPwd = encryptPassword(password)
|
||||
await mysql.query('UPDATE player SET password = ? WHERE username = ? AND email = ?', [encPwd, username, email])
|
||||
await mysql.query('DELETE FROM verify WHERE id = ? AND type = 2', [verifyRows[0].id])
|
||||
|
||||
log4js.koa.info('用户重置密码成功', username)
|
||||
return ok(ctx, {}, `${config.account.passwordSuffix}修改成功!`)
|
||||
})
|
||||
|
||||
// ─── POST /api/send_code 发送邮箱验证码 ──────────────────────────────────────
|
||||
router.post('/api/send_code', async (ctx) => {
|
||||
if (!config.code.open) return fail(ctx, '验证码系统尚未开启!')
|
||||
|
||||
const { username, email, type } = ctx.request.body // type: 1=注册 2=找回密码
|
||||
const typeInt = parseInt(type)
|
||||
const ip = getClientIp(ctx)
|
||||
|
||||
if (![1, 2].includes(typeInt)) return fail(ctx, '参数错误!')
|
||||
if (!username || !isValidAccount(username)) return fail(ctx, `请输入${config.account.name}${config.account.nameSuffix}`)
|
||||
if (!email || !isValidEmail(email)) return fail(ctx, '请输入正确的邮箱地址!')
|
||||
|
||||
if (1 === typeInt) {
|
||||
if (!config.account.regOpen) return fail(ctx, '内部测试中,未开放注册,如需体验请联系客服。')
|
||||
if (config.account.retainAccounts.includes(username.toLowerCase())) return fail(ctx, `此${config.account.name}已被占用,请更换。`)
|
||||
// 每日注册限制
|
||||
if (config.account.dayMaxReg) {
|
||||
const [regRows] = await mysql.query(
|
||||
"SELECT id FROM player WHERE reg_ip = ? AND DATE(FROM_UNIXTIME(reg_time)) = CURDATE()",
|
||||
[ip]
|
||||
)
|
||||
if (regRows.length >= config.account.dayMaxReg) return fail(ctx, '您今日注册量已达上限,请明日再试~', 10)
|
||||
}
|
||||
// 检查账号是否已存在
|
||||
const [existRows] = await mysql.query('SELECT id FROM player WHERE username = ?', [username])
|
||||
if (existRows.length > 0) return fail(ctx, `此${config.account.name}已被其他勇士占用!请更换。`)
|
||||
// 检查邮箱是否已被占用
|
||||
const [emailRows] = await mysql.query('SELECT id FROM player WHERE email = ?', [email])
|
||||
if (emailRows.length > 0) return fail(ctx, '此邮箱地址已被其他勇士占用!请更换。')
|
||||
} else {
|
||||
// 找回密码:检查账号+邮箱是否匹配
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT id FROM player WHERE username = ? AND email = ?',
|
||||
[username, email]
|
||||
)
|
||||
if (!playerRows.length) return fail(ctx, '传送员无法匹配此账号,请检查!')
|
||||
}
|
||||
|
||||
// 检查发送间隔
|
||||
const nowTime = unixTime()
|
||||
const [existVerify] = await mysql.query(
|
||||
'SELECT id, time FROM verify WHERE account = ? AND email = ? AND type = ?',
|
||||
[username, email, typeInt]
|
||||
)
|
||||
if (existVerify.length > 0) {
|
||||
const leftTime = config.code.sendInterval - (nowTime - existVerify[0].time)
|
||||
if (leftTime > 0) return fail(ctx, `操作频繁!请${leftTime}秒后再发送~`, 1)
|
||||
}
|
||||
|
||||
const code = generateCode(config.code.length, 'NUMBER')
|
||||
const sent = await sendCodeMail(email, username, code, typeInt)
|
||||
if (!sent) return fail(ctx, '验证码发送失败!请重试~')
|
||||
|
||||
if (existVerify.length > 0) {
|
||||
await mysql.query(
|
||||
'UPDATE verify SET code = ?, time = ?, ip = ? WHERE id = ? AND type = ?',
|
||||
[code, nowTime, ip, existVerify[0].id, typeInt]
|
||||
)
|
||||
} else {
|
||||
await mysql.query(
|
||||
'INSERT INTO verify (account, type, email, code, time, ip) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[username, typeInt, email, code, nowTime, ip]
|
||||
)
|
||||
}
|
||||
|
||||
return ok(ctx, { time: config.code.sendInterval }, `验证码已发送到您的邮箱:${email},请查收!`)
|
||||
})
|
||||
|
||||
// ─── POST /api/enter_game 进入游戏 ───────────────────────────────────────────
|
||||
router.post('/api/enter_game', async (ctx) => {
|
||||
const { srvId, account } = ctx.request.body
|
||||
if (!srvId || !account) return fail(ctx, '参数错误')
|
||||
const ip = getClientIp(ctx)
|
||||
log4js.koa.info('用户进入游戏', account, `srvId=${srvId}`, ip)
|
||||
await mysql.query(
|
||||
'UPDATE player SET login_time = ?, login_ip = ? WHERE username = ?',
|
||||
[time(), ip, account]
|
||||
)
|
||||
return ok(ctx, {}, '进入游戏成功')
|
||||
})
|
||||
|
||||
// ─── POST /api/check Token 校验(兼容旧版游戏客户端,接受 md5 密码 token)────
|
||||
// 旧版游戏客户端传 account + token(md5密码哈希),此接口验证并返回 JWT
|
||||
router.post('/api/check', async (ctx) => {
|
||||
const { account, token } = ctx.request.body
|
||||
if (!account || !token) return fail(ctx, '参数错误')
|
||||
|
||||
const [rows] = await mysql.query(
|
||||
'SELECT * FROM player WHERE username = ? AND password = ?',
|
||||
[account, token]
|
||||
)
|
||||
if (!rows?.length) return fail(ctx, '账号验证失败')
|
||||
|
||||
// 签发 JWT 供后续接口使用
|
||||
const jwtToken = jwt.sign({ ...rows[0] }, process.env.SECRET_KEY || 'chuanqi_secret', { expiresIn: '24h' })
|
||||
return ok(ctx, { token: jwtToken, account }, '验证成功')
|
||||
})
|
||||
|
||||
// ─── GET /api/check Token 验证(GET 方式,部分游戏客户端使用 query 参数)────
|
||||
router.get('/api/check', async (ctx) => {
|
||||
const { account, token } = ctx.query
|
||||
if (!account || !token) return fail(ctx, '参数错误')
|
||||
|
||||
const [rows] = await mysql.query(
|
||||
'SELECT id, username FROM player WHERE username = ? AND password = ?',
|
||||
[account, token]
|
||||
)
|
||||
if (!rows?.length) return fail(ctx, '账号验证失败')
|
||||
return ok(ctx, { account }, '验证成功')
|
||||
})
|
||||
|
||||
export default router.routes()
|
||||
|
||||
22
module/server/koa/middleware/errorHandler.js
Normal file
22
module/server/koa/middleware/errorHandler.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as log4js from '../../log4js.js'
|
||||
|
||||
/**
|
||||
* 统一错误处理中间件
|
||||
* 捕获所有未处理异常,规范化错误响应格式,避免泄露内部错误信息
|
||||
*/
|
||||
export default async function errorHandler(ctx, next) {
|
||||
try {
|
||||
await next()
|
||||
} catch (err) {
|
||||
log4js.koa.error(`[${ctx.method}] ${ctx.path} — ${err.message}`, err.stack || '')
|
||||
|
||||
// 已知业务错误(主动 throw new Error)直接返回消息
|
||||
if (err.status) {
|
||||
ctx.status = err.status
|
||||
ctx.body = { code: err.status, message: err.message || '请求错误' }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { code: 500, message: '服务器内部错误,请稍后再试!' }
|
||||
}
|
||||
}
|
||||
}
|
||||
19
module/server/koa/middleware/ipFilter.js
Normal file
19
module/server/koa/middleware/ipFilter.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import config from '../../config/index.js'
|
||||
import { getClientIp } from '../../utils.js'
|
||||
|
||||
/**
|
||||
* IP 黑名单过滤中间件
|
||||
* 对所有 /api/* 请求检查,命中封禁列表时返回 403
|
||||
*/
|
||||
export default async function ipFilter(ctx, next) {
|
||||
if (!ctx.path.startsWith('/api/')) {
|
||||
return next()
|
||||
}
|
||||
const ip = getClientIp(ctx)
|
||||
if (config.account.denyIps && config.account.denyIps.includes(ip)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { code: 403, message: '当前未开放访问!' }
|
||||
return
|
||||
}
|
||||
return next()
|
||||
}
|
||||
61
module/server/koa/middleware/rateLimiter.js
Normal file
61
module/server/koa/middleware/rateLimiter.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as log4js from '../../log4js.js'
|
||||
import { getClientIp } from '../../utils.js'
|
||||
|
||||
/**
|
||||
* 简单内存限流中间件(基于滑动窗口计数)
|
||||
*
|
||||
* 默认规则:
|
||||
* - 注册/发验证码:每 IP 每分钟最多 5 次
|
||||
* - 登录:每 IP 每分钟最多 20 次
|
||||
* - 其余 /api/*:每 IP 每分钟最多 100 次
|
||||
*
|
||||
* 生产环境建议替换为 Redis 方案以支持多实例
|
||||
*/
|
||||
|
||||
// Map<ip+path, [timestamps...]>
|
||||
const requestMap = new Map()
|
||||
|
||||
// 配置:[path前缀/全路径, 时间窗口(ms), 最大请求数]
|
||||
const RULES = [
|
||||
['/api/register', 60_000, 5],
|
||||
['/api/send_code', 60_000, 5],
|
||||
['/api/login', 60_000, 20],
|
||||
['/api/', 60_000, 200],
|
||||
]
|
||||
|
||||
// 定时清理过期记录,避免内存泄漏
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, timestamps] of requestMap.entries()) {
|
||||
const fresh = timestamps.filter(t => now - t < 60_000)
|
||||
if (fresh.length === 0) requestMap.delete(key)
|
||||
else requestMap.set(key, fresh)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
export default async function rateLimiter(ctx, next) {
|
||||
if (!ctx.path.startsWith('/api/')) return next()
|
||||
|
||||
const ip = getClientIp(ctx)
|
||||
const now = Date.now()
|
||||
|
||||
// 匹配第一条符合的规则
|
||||
const rule = RULES.find(([prefix]) => ctx.path.startsWith(prefix))
|
||||
if (!rule) return next()
|
||||
|
||||
const [prefix, windowMs, maxReq] = rule
|
||||
const key = `${ip}:${prefix}`
|
||||
|
||||
const timestamps = (requestMap.get(key) || []).filter(t => now - t < windowMs)
|
||||
timestamps.push(now)
|
||||
requestMap.set(key, timestamps)
|
||||
|
||||
if (timestamps.length > maxReq) {
|
||||
log4js.koa.warn(`限流触发: ${ip} ${ctx.path} (${timestamps.length}/${maxReq})`)
|
||||
ctx.status = 429
|
||||
ctx.body = { code: 429, message: '请求过于频繁,请稍后再试!' }
|
||||
return
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
@@ -1,5 +1,194 @@
|
||||
import Router from 'koa-router';
|
||||
import Router from 'koa-router'
|
||||
import mysql from '../mysql/index.js'
|
||||
import getGameDB from '../mysql/gameDB.js'
|
||||
import * as log4js from '../log4js.js'
|
||||
import config from '../config/index.js'
|
||||
import { time, unixTime, getClientIp } from '../utils.js'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const router = new Router()
|
||||
|
||||
function ok(ctx, data = {}, message = '操作成功') {
|
||||
ctx.body = { code: 0, message, ...data }
|
||||
}
|
||||
function fail(ctx, message = '操作失败', code = 1) {
|
||||
ctx.body = { code, message }
|
||||
}
|
||||
|
||||
// ─── GET /api/server/list 区服列表 ──────────────────────────────────────────
|
||||
router.get('/api/server/list', async (ctx) => {
|
||||
const account = ctx.query.account || ''
|
||||
const nowTime = unixTime()
|
||||
const newSrvTime = 7 * 24 * 60 * 60
|
||||
|
||||
const [rows] = await mysql.query(
|
||||
'SELECT id, server_id, name, host, port, status, UNIX_TIMESTAMP(time) as time, merge_id FROM server WHERE status >= 1 ORDER BY server_id ASC LIMIT 1000'
|
||||
)
|
||||
|
||||
const serverlist = rows.map(row => {
|
||||
const sid = row.merge_id || row.server_id
|
||||
return {
|
||||
id: row.id,
|
||||
serverName: (row.name || config.game.firstName) + row.server_id + '区',
|
||||
srvaddr: (row.host && row.host !== '127.0.0.1') ? row.host : config.game.host,
|
||||
srvport: row.port || (config.game.port + sid),
|
||||
srvid: sid,
|
||||
type: row.status === 3 ? 3 : (nowTime - row.time <= newSrvTime ? 1 : 2), // 1:新 2:火爆 3:维护
|
||||
opentime: time(row.time * 1000),
|
||||
pf: config.game.pf,
|
||||
serverAlias: 's' + sid,
|
||||
originalSrvid: sid,
|
||||
}
|
||||
})
|
||||
|
||||
return ok(ctx, {
|
||||
login: [999, 997, 990],
|
||||
serverlist: [{ name: '全部区服', serverlist }]
|
||||
}, '获取服务器列表成功')
|
||||
})
|
||||
|
||||
// ─── GET /api/misc/agree 用户协议 ───────────────────────────────────────────
|
||||
// 优先从 config/agreement.html 文件读取,不存在则回退到 config.agree 字符串配置
|
||||
router.get('/api/misc/agree', async (ctx) => {
|
||||
const agreePath = join(__dirname, '../config/agreement.html')
|
||||
if (existsSync(agreePath)) {
|
||||
ctx.type = 'html'
|
||||
ctx.body = readFileSync(agreePath, 'utf-8')
|
||||
} else {
|
||||
ctx.body = config.agree
|
||||
}
|
||||
})
|
||||
|
||||
// ─── POST /api/report/chat 上报聊天 ─────────────────────────────────────────
|
||||
router.post('/api/report/chat', async (ctx) => {
|
||||
const { server_id, account, role_id, channel_id, content, cross } = ctx.request.body
|
||||
const serverId = parseInt((server_id || '').toString().replace(/^s/, ''))
|
||||
|
||||
if (!serverId || !account || !role_id || !content) return fail(ctx, 'param error')
|
||||
if (account.length > 26) return fail(ctx, 'param error')
|
||||
if (parseInt(channel_id) > 10) return fail(ctx, 'param error')
|
||||
if (content.length > 255) return fail(ctx, 'param error')
|
||||
|
||||
// 验证账号 token
|
||||
const token = ctx.request.headers.authorization?.split(' ')[1]
|
||||
if (!token) return fail(ctx, '未授权', 401)
|
||||
|
||||
const nowTime = time()
|
||||
await mysql.query(
|
||||
'INSERT INTO chat (account, server_id, role_id, channel_id, content, is_cross, time) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[account, serverId, parseInt(role_id), parseInt(channel_id) || 0, content, cross == 1 ? 1 : 0, nowTime]
|
||||
)
|
||||
return ok(ctx)
|
||||
})
|
||||
|
||||
// ─── POST /api/game/withdraw 提现 ───────────────────────────────────────────
|
||||
router.post('/api/game/withdraw', async (ctx) => {
|
||||
const {
|
||||
server_id, account, role_id, role_name, pay_type, pay_account, amount
|
||||
} = ctx.request.body
|
||||
|
||||
const serverId = parseInt((server_id || '').toString().replace(/^s/, ''))
|
||||
const roleId = parseInt(role_id)
|
||||
const payType = parseInt(pay_type)
|
||||
const amountInt = parseInt(amount)
|
||||
|
||||
if (!serverId || !account || !roleId || !role_name || !pay_account || !amountInt) return fail(ctx, '参数错误!')
|
||||
if (account.length > 26) return fail(ctx, '参数错误!')
|
||||
if (role_name.length > 24) return fail(ctx, '参数错误!')
|
||||
if (![0, 1].includes(payType)) return fail(ctx, '收款账户类型不正确!')
|
||||
if (pay_account.length > 30) return fail(ctx, '收款账户格式不正确!')
|
||||
|
||||
const withdrawCfg = config.withdraw
|
||||
const currencyName = config.currency.list[withdrawCfg.type]
|
||||
const currencyField = config.currency.field[withdrawCfg.type]
|
||||
|
||||
if (amountInt < withdrawCfg.ratio) return fail(ctx, `最低提现数量为${withdrawCfg.ratio}`)
|
||||
const minAmount = withdrawCfg.ratio * withdrawCfg.minOnce
|
||||
if (amountInt < minAmount) return fail(ctx, `单次提现数量不能低于${minAmount}`)
|
||||
|
||||
// 验证账号
|
||||
const [playerRows] = await mysql.query(
|
||||
'SELECT id FROM player WHERE username = ?', [account]
|
||||
)
|
||||
if (!playerRows.length) return fail(ctx, '账号不存在!')
|
||||
|
||||
// 提现间隔限制
|
||||
const nowTime = unixTime()
|
||||
const [lastWithdraw] = await mysql.query(
|
||||
'SELECT UNIX_TIMESTAMP(time) as time FROM withdraw WHERE server_id = ? AND role_id = ? ORDER BY id DESC LIMIT 1',
|
||||
[serverId, roleId]
|
||||
)
|
||||
if (lastWithdraw.length > 0 && nowTime - lastWithdraw[0].time < withdrawCfg.intervalSec) {
|
||||
const leftSec = withdrawCfg.intervalSec - (nowTime - lastWithdraw[0].time)
|
||||
return fail(ctx, `请等待 ${leftSec} 秒后再试~`)
|
||||
}
|
||||
|
||||
// ── 连接游戏区服数据库,验证货币余额 ─────────────────────────────────────────
|
||||
let gameDB
|
||||
try {
|
||||
gameDB = getGameDB(serverId)
|
||||
} catch (e) {
|
||||
log4js.koa.error('连接游戏DB失败', serverId, e.message)
|
||||
return fail(ctx, '游戏服务器连接失败,请稍后再试!')
|
||||
}
|
||||
|
||||
// 查询角色货币余额(表名为 characters,字段由 currency.field 配置)
|
||||
let currentBalance = 0
|
||||
if (gameDB && currencyField) {
|
||||
try {
|
||||
const [charRows] = await gameDB.query(
|
||||
`SELECT \`${currencyField}\` as balance FROM characters WHERE id = ? LIMIT 1`,
|
||||
[roleId]
|
||||
)
|
||||
if (!charRows.length) return fail(ctx, '角色不存在,请确认区服和角色是否正确!')
|
||||
currentBalance = parseInt(charRows[0].balance) || 0
|
||||
} catch (e) {
|
||||
log4js.koa.error('查询角色余额失败', serverId, roleId, e.message)
|
||||
return fail(ctx, '查询角色数据失败,请稍后再试!')
|
||||
}
|
||||
|
||||
if (currentBalance < amountInt) {
|
||||
return fail(ctx, `您的${currencyName}余额不足(当前:${currentBalance},需要:${amountInt})`)
|
||||
}
|
||||
}
|
||||
|
||||
const money = Math.floor(amountInt / withdrawCfg.ratio)
|
||||
|
||||
// ── 调用游戏 GM 命令接口扣除货币 ─────────────────────────────────────────────
|
||||
const gmHost = config.game.host
|
||||
const gmPort = config.game.gmPort
|
||||
// GM 接口格式:operid=10030,扣除货币
|
||||
const gmUrl = `http://${gmHost}:${gmPort}/?operid=10030&serverid=${serverId}&roleid=${roleId}&type=${withdrawCfg.type}&num=${amountInt}`
|
||||
|
||||
let gmSuccess = false
|
||||
try {
|
||||
const gmRes = await fetch(gmUrl, { signal: AbortSignal.timeout(5000) })
|
||||
const gmText = await gmRes.text()
|
||||
// GM 接口返回 0 表示成功
|
||||
gmSuccess = gmText.trim() === '0' || gmText.includes('"result":0') || gmText.includes('success')
|
||||
log4js.koa.info(`GM 命令返回: ${gmText.trim()}`, gmUrl)
|
||||
} catch (e) {
|
||||
log4js.koa.error('GM 命令调用失败', e.message, gmUrl)
|
||||
return fail(ctx, '扣除货币失败,请联系客服!')
|
||||
}
|
||||
|
||||
if (!gmSuccess) {
|
||||
log4js.koa.warn('GM 命令返回失败', gmUrl)
|
||||
return fail(ctx, '货币扣除失败,请联系客服处理!')
|
||||
}
|
||||
|
||||
// ── 写入提现记录 ──────────────────────────────────────────────────────────────
|
||||
await mysql.query(
|
||||
'INSERT INTO withdraw (account, server_id, role_id, pay_type, pay_account, amount, money, time) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())',
|
||||
[account, serverId, roleId, payType, pay_account, amountInt, money]
|
||||
)
|
||||
|
||||
log4js.koa.info(`提现成功: ${account} s${serverId} ${role_name} ${amountInt}${currencyName}=${money}元`)
|
||||
return ok(ctx, {}, `成功提现:${amountInt}${currencyName}\n收益人民币:${money}元\n\n请留意您的收款账户余额。`)
|
||||
})
|
||||
|
||||
export default router.routes()
|
||||
|
||||
@@ -1,15 +1,88 @@
|
||||
import log4js from "log4js";
|
||||
import log4js from 'log4js'
|
||||
|
||||
// ── 日志目录(相对于进程工作目录,PM2 启动时建议设置 cwd 为 module/server) ──
|
||||
const LOG_DIR = process.env.LOG_DIR || 'logs'
|
||||
const LOG_LEVEL = process.env.LOG_LEVEL || 'info'
|
||||
|
||||
/**
|
||||
* log4js 配置:
|
||||
* - console : 控制台彩色输出(开发期友好)
|
||||
* - file : logs/app.log —— 所有 ≥ info 的日志,按天轮转,保留 30 天
|
||||
* - error : logs/error.log —— 仅 warn/error,按天轮转,保留 60 天
|
||||
*
|
||||
* 生产环境可通过环境变量控制:
|
||||
* LOG_DIR 日志目录 默认 logs
|
||||
* LOG_LEVEL 日志级别 默认 info
|
||||
*/
|
||||
export const configure = {
|
||||
appenders: {
|
||||
console: {type: "console"},
|
||||
// 控制台
|
||||
console: {
|
||||
type: 'console',
|
||||
layout: {
|
||||
type: 'pattern',
|
||||
pattern: '%[[%d{hh:mm:ss}] [%p] [%c]%] %m',
|
||||
},
|
||||
},
|
||||
|
||||
// 全量文件日志(按日期轮转)
|
||||
file: {
|
||||
type: 'dateFile',
|
||||
filename: `${LOG_DIR}/app`,
|
||||
pattern: '.yyyy-MM-dd.log',
|
||||
alwaysIncludePattern: true,
|
||||
layout: {
|
||||
type: 'pattern',
|
||||
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] [%c] %m',
|
||||
},
|
||||
numBackups: 30, // 保留最近 30 天
|
||||
compress: true, // 压缩旧日志
|
||||
keepFileExt: false,
|
||||
},
|
||||
|
||||
// 错误日志(仅 WARN / ERROR)
|
||||
errorFile: {
|
||||
type: 'dateFile',
|
||||
filename: `${LOG_DIR}/error`,
|
||||
pattern: '.yyyy-MM-dd.log',
|
||||
alwaysIncludePattern: true,
|
||||
layout: {
|
||||
type: 'pattern',
|
||||
pattern: '[%d{yyyy-MM-dd hh:mm:ss}] [%p] [%c] %m',
|
||||
},
|
||||
numBackups: 60, // 保留 60 天
|
||||
compress: true,
|
||||
},
|
||||
|
||||
// 过滤器:只让 WARN 及以上进入 errorFile
|
||||
errorFilter: {
|
||||
type: 'logLevelFilter',
|
||||
appender: 'errorFile',
|
||||
level: 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
categories: {
|
||||
default: {appenders: ["console"], level: "ALL"},
|
||||
// 开发模式:只输出到控制台
|
||||
default: {
|
||||
appenders: process.env.NODE_ENV === 'production'
|
||||
? ['console', 'file', 'errorFilter']
|
||||
: ['console'],
|
||||
level: LOG_LEVEL,
|
||||
},
|
||||
|
||||
// MySQL 日志独立分类(可在需要时调整级别)
|
||||
mysql: {
|
||||
appenders: process.env.NODE_ENV === 'production'
|
||||
? ['file', 'errorFilter']
|
||||
: ['console'],
|
||||
level: 'warn', // MySQL 日志默认只记录 warn 及以上
|
||||
},
|
||||
},
|
||||
}
|
||||
log4js.configure(configure)
|
||||
export const manager = log4js
|
||||
export const mysql = log4js.getLogger("mysql")
|
||||
export const koa = log4js.getLogger("koa")
|
||||
|
||||
log4js.configure(configure)
|
||||
|
||||
export const manager = log4js
|
||||
export const mysql = log4js.getLogger('mysql')
|
||||
export const koa = log4js.getLogger('koa')
|
||||
|
||||
51
module/server/mail.js
Normal file
51
module/server/mail.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import config from './config/index.js'
|
||||
import * as log4js from './log4js.js'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.mail.host,
|
||||
port: config.mail.port,
|
||||
secure: config.mail.secure,
|
||||
auth: {
|
||||
user: config.mail.from,
|
||||
pass: config.mail.password,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param {string} to 收件人邮箱
|
||||
* @param {string} account 游戏账号
|
||||
* @param {string} code 验证码
|
||||
* @param {number} type 1=注册 2=找回密码
|
||||
*/
|
||||
export async function sendCodeMail(to, account, code, type) {
|
||||
const typeNames = { 1: '注册', 2: '找回密码' }
|
||||
const typeName = typeNames[type] || '验证'
|
||||
const subject = `【${config.game.name}】${typeName}`
|
||||
const html = `
|
||||
<div style="background:#000;padding:50px;min-height:300px;">
|
||||
<div style="background:rgba(0,0,0,.7);border-radius:6px;color:#fff;padding:25px;max-width:450px;">
|
||||
<h3>${subject}</h3>
|
||||
<p>您的${config.account.name}${config.account.nameSuffix}:<strong>${account}</strong></p>
|
||||
<p>您的验证码:<span style="font-weight:700;font-size:18px;text-decoration:underline;color:#f5bd10;">${code}</span></p>
|
||||
<p style="color:#aaa;">用于${typeName}验证,5分钟内有效。</p>
|
||||
<hr style="border-color:#333;"/>
|
||||
<p style="font-size:12px;color:#666;">${config.game.name} · ${config.game.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: `"${config.game.name}" <${config.mail.from}>`,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
log4js.koa.info(`验证码邮件发送成功 -> ${to}`)
|
||||
return true
|
||||
} catch (err) {
|
||||
log4js.koa.error(`验证码邮件发送失败 -> ${to}`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
46
module/server/mysql/gameDB.js
Normal file
46
module/server/mysql/gameDB.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 游戏区服动态数据库连接工具
|
||||
*
|
||||
* 游戏每个区服对应独立的数据库 mir_actor_s{serverId}
|
||||
* 该模块根据 serverId 动态创建连接池(带缓存,同一区服复用连接)
|
||||
*
|
||||
* 使用示例:
|
||||
* import getGameDB from '../mysql/gameDB.js'
|
||||
* const db = getGameDB(1)
|
||||
* const [rows] = await db.query('SELECT ...')
|
||||
*/
|
||||
|
||||
import mysql from 'mysql2'
|
||||
import config from '../config/index.js'
|
||||
import * as log4js from '../log4js.js'
|
||||
|
||||
// 连接池缓存,避免对同一区服重复创建
|
||||
const poolCache = new Map()
|
||||
|
||||
/**
|
||||
* 获取指定区服的 MySQL 连接池(Promise 包装)
|
||||
* @param {number} serverId 区服 ID
|
||||
* @returns {import('mysql2/promise').Pool}
|
||||
*/
|
||||
export default function getGameDB(serverId) {
|
||||
const dbName = `mir_actor_s${serverId}`
|
||||
if (poolCache.has(dbName)) return poolCache.get(dbName)
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: config.game.dbHost || config.mysql.host,
|
||||
port: config.game.dbPort || config.mysql.port,
|
||||
user: config.game.dbUser || config.mysql.user,
|
||||
password: config.game.dbPassword || config.mysql.password,
|
||||
database: dbName,
|
||||
connectionLimit: 5,
|
||||
waitForConnections: true,
|
||||
})
|
||||
|
||||
pool.on('error', (err) => {
|
||||
log4js.mysql.error(`[${dbName}] 连接池错误:`, err.message)
|
||||
})
|
||||
|
||||
const promisePool = pool.promise()
|
||||
poolCache.set(dbName, promisePool)
|
||||
return promisePool
|
||||
}
|
||||
@@ -7,18 +7,29 @@ const pool = mysql.createPool({
|
||||
port: config.mysql.port,
|
||||
user: config.mysql.user,
|
||||
password: config.mysql.password,
|
||||
database: config.mysql.database,
|
||||
connectionLimit: 10,
|
||||
queryFormat: function (sql, values) {
|
||||
const opts = { sql, values }
|
||||
this._resolveNamedPlaceholders(opts)
|
||||
log4js.mysql.debug(opts.sql, opts.values)
|
||||
return mysql.format(
|
||||
opts.sql,
|
||||
opts.values,
|
||||
this.config.stringifyObjects,
|
||||
this.config.timezone
|
||||
)
|
||||
}
|
||||
// 不在启动时立即建立连接,等第一次查询时再连接
|
||||
waitForConnections: true,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 10000,
|
||||
});
|
||||
|
||||
export default pool.promise();
|
||||
// 监听连接错误,避免未处理的 Promise rejection 导致进程崩溃
|
||||
pool.on('connection', (connection) => {
|
||||
log4js.mysql.info(`MySQL 连接建立 [id=${connection.threadId}] ${config.mysql.host}:${config.mysql.port}`);
|
||||
});
|
||||
pool.on('error', (err) => {
|
||||
log4js.mysql.error('MySQL 连接池错误:', err.message);
|
||||
});
|
||||
|
||||
const promisePool = pool.promise();
|
||||
|
||||
// 健康检查:启动时 ping 一次数据库,失败只警告不崩溃
|
||||
promisePool.query('SELECT 1').then(() => {
|
||||
log4js.mysql.info(`MySQL 连接成功 ${config.mysql.host}:${config.mysql.port}/${config.mysql.database}`);
|
||||
}).catch((err) => {
|
||||
log4js.mysql.warn(`MySQL 连接失败(服务仍将继续运行): ${err.message}`);
|
||||
});
|
||||
|
||||
export default promisePool;
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
"dayjs": "^1.11.19",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"koa": "^2.15.0",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-router": "^12.0.0",
|
||||
"koa-static": "^5.0.0",
|
||||
"log4js": "^6.9.1",
|
||||
"mysql2": "^3.16.0"
|
||||
"mysql2": "^3.16.0",
|
||||
"nodemailer": "^6.9.16"
|
||||
}
|
||||
}
|
||||
|
||||
1
module/server/start_out.txt
Normal file
1
module/server/start_out.txt
Normal file
@@ -0,0 +1 @@
|
||||
[32m[11:46:24] [INFO] [koa][39m 🚀 Koa server running on port 3001
|
||||
@@ -1,5 +1,117 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayjs from 'dayjs'
|
||||
import crypto from 'crypto'
|
||||
|
||||
export function time(date){
|
||||
// 密码加密 key(与原 PHP 保持一致)
|
||||
const PASSWORD_KEY = process.env.PASSWORD_KEY || 'WVImV8mIMnpY9Lrmh3yoaJ2yRLNACBfg'
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export function time(date) {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 Unix 时间戳(秒)
|
||||
*/
|
||||
export function unixTime() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* md5 加密(兼容 PHP md5())
|
||||
*/
|
||||
export function md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 对密码做 md5+key 加密(与原 PHP PASSWORD_KEY 逻辑一致)
|
||||
*/
|
||||
export function encryptPassword(password) {
|
||||
return md5(password + PASSWORD_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机验证码
|
||||
* @param {number} length 长度
|
||||
* @param {'NUMBER'|'CHAR'|'ALL'} type 类型
|
||||
*/
|
||||
export function generateCode(length = 6, type = 'NUMBER') {
|
||||
const chars = {
|
||||
NUMBER: '0123456789',
|
||||
CHAR: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
ALL: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
}
|
||||
const pool = chars[type] || chars.NUMBER
|
||||
let code = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += pool[Math.floor(Math.random() * pool.length)]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取客户端真实 IP(支持代理)
|
||||
*/
|
||||
export function getClientIp(ctx) {
|
||||
return (
|
||||
ctx.request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
ctx.request.headers['x-real-ip'] ||
|
||||
ctx.ip
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验账号格式(6-16位字母数字下划线)
|
||||
*/
|
||||
export function isValidAccount(account) {
|
||||
return /^[a-zA-Z0-9_]{6,16}$/.test(account)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验邮箱格式
|
||||
*/
|
||||
export function isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 User-Agent 解析设备/系统/浏览器信息(与 PHP function.php 保持一致)
|
||||
* @param {string} ua
|
||||
* @returns {{ device: string, os: string, browse: string }}
|
||||
*/
|
||||
export function getDeviceInfo(ua = '') {
|
||||
const uaLower = ua.toLowerCase()
|
||||
|
||||
// 设备类型
|
||||
let device = 'pc'
|
||||
if (/mobile|android|iphone|ipad|ipod|windows phone/i.test(ua)) device = 'mobile'
|
||||
|
||||
// 操作系统
|
||||
let os = 'Other'
|
||||
if (/windows nt 10/i.test(ua)) os = 'Windows 10'
|
||||
else if (/windows nt 6\.3/i.test(ua)) os = 'Windows 8.1'
|
||||
else if (/windows nt 6\.2/i.test(ua)) os = 'Windows 8'
|
||||
else if (/windows nt 6\.1/i.test(ua)) os = 'Windows 7'
|
||||
else if (/windows nt 6\.0/i.test(ua)) os = 'Windows Vista'
|
||||
else if (/windows nt 5\.1/i.test(ua)) os = 'Windows XP'
|
||||
else if (/windows/i.test(ua)) os = 'Windows'
|
||||
else if (/android (\d+\.\d+)/i.test(ua)) os = 'Android ' + ua.match(/android (\d+\.\d+)/i)[1]
|
||||
else if (/iphone os (\d+_\d+)/i.test(ua)) os = 'iOS ' + ua.match(/iphone os (\d+_\d+)/i)[1].replace('_', '.')
|
||||
else if (/ipad.*os (\d+_\d+)/i.test(ua)) os = 'iPadOS ' + ua.match(/ipad.*os (\d+_\d+)/i)[1].replace('_', '.')
|
||||
else if (/mac os x/i.test(ua)) os = 'macOS'
|
||||
else if (/linux/i.test(ua)) os = 'Linux'
|
||||
|
||||
// 浏览器
|
||||
let browse = 'Other'
|
||||
if (/edg\//i.test(ua)) browse = 'Edge'
|
||||
else if (/opr\//i.test(ua) || /opera/i.test(ua)) browse = 'Opera'
|
||||
else if (/chrome\/(\d+)/i.test(ua)) browse = 'Chrome ' + ua.match(/chrome\/(\d+)/i)[1]
|
||||
else if (/firefox\/(\d+)/i.test(ua)) browse = 'Firefox ' + ua.match(/firefox\/(\d+)/i)[1]
|
||||
else if (/safari\/(\d+)/i.test(ua) && !/chrome/i.test(ua)) browse = 'Safari'
|
||||
else if (/micromessenger/i.test(ua)) browse = '微信'
|
||||
else if (/mqqbrowser/i.test(ua)) browse = 'QQ浏览器'
|
||||
|
||||
return { device, os, browse }
|
||||
}
|
||||
|
||||
27
module/web/build_out.txt
Normal file
27
module/web/build_out.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
npm warn Unknown user config "home". This will stop working in the next major version of npm.
|
||||
[36mvite v7.3.1 [32mbuilding client environment for production...[36m[39m
|
||||
<script src="/static/js/md5.js"> in "/index.html" can't be bundled without type="module" attribute
|
||||
<script src="/static/js/common.js?v=1"> in "/index.html" can't be bundled without type="module" attribute
|
||||
transforming...
|
||||
[32m✓[39m 1670 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
[2mdist/[22m[32mindex.html [39m[1m[2m 1.05 kB[22m[1m[22m[2m │ gzip: 0.49 kB[22m
|
||||
[2mdist/[22m[35massets/agree-BLQpfDVx.css [39m[1m[2m 1.37 kB[22m[1m[22m[2m │ gzip: 0.46 kB[22m
|
||||
[2mdist/[22m[35massets/linuxdo-bind-DPWWajSf.css [39m[1m[2m 1.77 kB[22m[1m[22m[2m │ gzip: 0.53 kB[22m
|
||||
[2mdist/[22m[35massets/withdraw-DqWlBzRo.css [39m[1m[2m 3.60 kB[22m[1m[22m[2m │ gzip: 0.81 kB[22m
|
||||
[2mdist/[22m[35massets/login-BlSp8Yei.css [39m[1m[2m 4.29 kB[22m[1m[22m[2m │ gzip: 1.16 kB[22m
|
||||
[2mdist/[22m[35massets/index-KHprj2W-.css [39m[1m[2m378.12 kB[22m[1m[22m[2m │ gzip: 53.06 kB[22m
|
||||
[2mdist/[22m[36massets/agree-C-28jhbg.js [39m[1m[2m 0.92 kB[22m[1m[22m[2m │ gzip: 0.63 kB[22m
|
||||
[2mdist/[22m[36massets/linuxdo-bind-CFqSeBhv.js [39m[1m[2m 2.55 kB[22m[1m[22m[2m │ gzip: 1.25 kB[22m
|
||||
[2mdist/[22m[36massets/index-7GZNUh9s.js [39m[1m[2m 3.66 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
[2mdist/[22m[36massets/withdraw-09hftHnJ.js [39m[1m[2m 4.85 kB[22m[1m[22m[2m │ gzip: 2.24 kB[22m
|
||||
[2mdist/[22m[36massets/login-kOeQ-hLG.js [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 2.60 kB[22m
|
||||
[2mdist/[22m[36massets/request-CitDfMDA.js [39m[1m[2m 37.58 kB[22m[1m[22m[2m │ gzip: 15.14 kB[22m
|
||||
[2mdist/[22m[36massets/index-DjlCHBNb.js [39m[1m[33m988.31 kB[39m[22m[2m │ gzip: 326.73 kB[22m
|
||||
[33m
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.[39m
|
||||
[32m✓ built in 11.94s[39m
|
||||
25
module/web/build_out2.txt
Normal file
25
module/web/build_out2.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
npm warn Unknown user config "home". This will stop working in the next major version of npm.
|
||||
[36mvite v7.3.1 [32mbuilding client environment for production...[36m[39m
|
||||
<script src="/static/js/md5.js"> in "/index.html" can't be bundled without type="module" attribute
|
||||
<script src="/static/js/common.js?v=1"> in "/index.html" can't be bundled without type="module" attribute
|
||||
transforming...
|
||||
[32m✓[39m 1670 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
[2mdist/[22m[32mindex.html [39m[1m[2m 1.21 kB[22m[1m[22m[2m │ gzip: 0.54 kB[22m
|
||||
[2mdist/[22m[35massets/agree-BLQpfDVx.css [39m[1m[2m 1.37 kB[22m[1m[22m[2m │ gzip: 0.46 kB[22m
|
||||
[2mdist/[22m[35massets/linuxdo-bind-DPWWajSf.css [39m[1m[2m 1.77 kB[22m[1m[22m[2m │ gzip: 0.53 kB[22m
|
||||
[2mdist/[22m[35massets/withdraw-DqWlBzRo.css [39m[1m[2m 3.60 kB[22m[1m[22m[2m │ gzip: 0.81 kB[22m
|
||||
[2mdist/[22m[35massets/login-Dg3cbkYJ.css [39m[1m[2m 4.27 kB[22m[1m[22m[2m │ gzip: 1.16 kB[22m
|
||||
[2mdist/[22m[35massets/index-KHprj2W-.css [39m[1m[2m378.12 kB[22m[1m[22m[2m │ gzip: 53.06 kB[22m
|
||||
[2mdist/[22m[36massets/request-Cp8a3QvY.js [39m[1m[2m 0.56 kB[22m[1m[22m[2m │ gzip: 0.42 kB[22m
|
||||
[2mdist/[22m[36massets/agree-BcUYvO61.js [39m[1m[2m 1.00 kB[22m[1m[22m[2m │ gzip: 0.67 kB[22m
|
||||
[2mdist/[22m[36massets/linuxdo-bind-Dwzc1_1I.js [39m[1m[2m 2.67 kB[22m[1m[22m[2m │ gzip: 1.31 kB[22m
|
||||
[2mdist/[22m[36massets/index-DuMdIoFP.js [39m[1m[2m 3.74 kB[22m[1m[22m[2m │ gzip: 1.98 kB[22m
|
||||
[2mdist/[22m[36massets/index-C39LcZDB.js [39m[1m[2m 4.34 kB[22m[1m[22m[2m │ gzip: 2.08 kB[22m
|
||||
[2mdist/[22m[36massets/withdraw-BUvhXBBH.js [39m[1m[2m 4.97 kB[22m[1m[22m[2m │ gzip: 2.30 kB[22m
|
||||
[2mdist/[22m[36massets/login-kvI3nDLX.js [39m[1m[2m 6.79 kB[22m[1m[22m[2m │ gzip: 2.67 kB[22m
|
||||
[2mdist/[22m[36massets/vendor-axios-42ANG6Sg.js [39m[1m[2m 37.09 kB[22m[1m[22m[2m │ gzip: 14.85 kB[22m
|
||||
[2mdist/[22m[36massets/vendor-vue-CrCesgj8.js [39m[1m[2m105.59 kB[22m[1m[22m[2m │ gzip: 40.98 kB[22m
|
||||
[2mdist/[22m[36massets/vendor-element-DlG_uBZr.js [39m[1m[2m878.48 kB[22m[1m[22m[2m │ gzip: 284.60 kB[22m
|
||||
[32m✓ built in 11.71s[39m
|
||||
27
module/web/build_output.txt
Normal file
27
module/web/build_output.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
npm warn Unknown user config "home". This will stop working in the next major version of npm.
|
||||
[36mvite v7.3.1 [32mbuilding client environment for production...[36m[39m
|
||||
<script src="/static/js/md5.js"> in "/index.html" can't be bundled without type="module" attribute
|
||||
<script src="/static/js/common.js?v=1"> in "/index.html" can't be bundled without type="module" attribute
|
||||
transforming...
|
||||
[32m✓[39m 1670 modules transformed.
|
||||
rendering chunks...
|
||||
computing gzip size...
|
||||
[2mdist/[22m[32mindex.html [39m[1m[2m 1.05 kB[22m[1m[22m[2m │ gzip: 0.49 kB[22m
|
||||
[2mdist/[22m[35massets/agree-BLQpfDVx.css [39m[1m[2m 1.37 kB[22m[1m[22m[2m │ gzip: 0.46 kB[22m
|
||||
[2mdist/[22m[35massets/linuxdo-bind-DPWWajSf.css [39m[1m[2m 1.77 kB[22m[1m[22m[2m │ gzip: 0.53 kB[22m
|
||||
[2mdist/[22m[35massets/withdraw-DqWlBzRo.css [39m[1m[2m 3.60 kB[22m[1m[22m[2m │ gzip: 0.81 kB[22m
|
||||
[2mdist/[22m[35massets/login-BlSp8Yei.css [39m[1m[2m 4.29 kB[22m[1m[22m[2m │ gzip: 1.16 kB[22m
|
||||
[2mdist/[22m[35massets/index-KHprj2W-.css [39m[1m[2m378.12 kB[22m[1m[22m[2m │ gzip: 53.06 kB[22m
|
||||
[2mdist/[22m[36massets/agree-C-28jhbg.js [39m[1m[2m 0.92 kB[22m[1m[22m[2m │ gzip: 0.63 kB[22m
|
||||
[2mdist/[22m[36massets/linuxdo-bind-CFqSeBhv.js [39m[1m[2m 2.55 kB[22m[1m[22m[2m │ gzip: 1.25 kB[22m
|
||||
[2mdist/[22m[36massets/index-7GZNUh9s.js [39m[1m[2m 3.66 kB[22m[1m[22m[2m │ gzip: 1.94 kB[22m
|
||||
[2mdist/[22m[36massets/withdraw-09hftHnJ.js [39m[1m[2m 4.85 kB[22m[1m[22m[2m │ gzip: 2.24 kB[22m
|
||||
[2mdist/[22m[36massets/login-kOeQ-hLG.js [39m[1m[2m 6.67 kB[22m[1m[22m[2m │ gzip: 2.60 kB[22m
|
||||
[2mdist/[22m[36massets/request-CitDfMDA.js [39m[1m[2m 37.58 kB[22m[1m[22m[2m │ gzip: 15.14 kB[22m
|
||||
[2mdist/[22m[36massets/index-DjlCHBNb.js [39m[1m[33m988.31 kB[39m[22m[2m │ gzip: 326.73 kB[22m
|
||||
[33m
|
||||
(!) Some chunks are larger than 500 kB after minification. Consider:
|
||||
- Using dynamic import() to code-split the application
|
||||
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
|
||||
- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.[39m
|
||||
[32m✓ built in 17.40s[39m
|
||||
@@ -1,23 +1,69 @@
|
||||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
import Index from "../views/index.vue";
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// 需要登录才能访问的路由名称
|
||||
const AUTH_ROUTES = ['Index', 'Withdraw']
|
||||
|
||||
// 不需要登录即可访问的路由名称(白名单)
|
||||
const PUBLIC_ROUTES = ['Login', 'LinuxdoBind', 'Agree']
|
||||
|
||||
// 示例路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Index,
|
||||
redirect: '/login'
|
||||
},
|
||||
{
|
||||
path: '/login', name: 'Login',
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login.vue')
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/linuxdo-bind',
|
||||
name: 'LinuxdoBind',
|
||||
component: () => import('@/views/linuxdo-bind.vue')
|
||||
},
|
||||
{
|
||||
path: '/index',
|
||||
name: 'Index',
|
||||
component: () => import('@/views/index.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/play',
|
||||
name: 'Play',
|
||||
redirect: '/index',
|
||||
},
|
||||
{
|
||||
path: '/agree',
|
||||
name: 'Agree',
|
||||
component: () => import('@/views/agree.vue')
|
||||
},
|
||||
{
|
||||
path: '/withdraw',
|
||||
name: 'Withdraw',
|
||||
component: () => import('@/views/withdraw.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
routes,
|
||||
})
|
||||
|
||||
// ─── 全局路由守卫 ─────────────────────────────────────────────────────────────
|
||||
router.beforeEach((to, from, next) => {
|
||||
const token = sessionStorage.getItem('CQ-TOKEN')
|
||||
const isLoggedIn = !!token
|
||||
|
||||
if (to.meta?.requiresAuth && !isLoggedIn) {
|
||||
// 需要登录但未登录 → 跳转登录页
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
} else if (to.name === 'Login' && isLoggedIn) {
|
||||
// 已登录访问登录页 → 跳转游戏主页
|
||||
next({ path: '/index' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import axios from "axios";
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const ins = axios.create({
|
||||
baseURL: '/',
|
||||
timeout: 10000,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截:自动附带 token
|
||||
ins.interceptors.request.use(config => {
|
||||
const token = sessionStorage.getItem('CQ-TOKEN')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
ins.interceptors.response.use(res => {
|
||||
return res.data
|
||||
})
|
||||
|
||||
// 响应拦截:统一处理错误
|
||||
ins.interceptors.response.use(
|
||||
res => {
|
||||
return res.data
|
||||
},
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
sessionStorage.removeItem('CQ-TOKEN')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
window.location.href = '/login'
|
||||
} else {
|
||||
ElMessage.error(err.response?.data?.message || '网络错误,请稍后重试')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default ins
|
||||
|
||||
113
module/web/src/views/agree.vue
Normal file
113
module/web/src/views/agree.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const content = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/misc/agree')
|
||||
if (!res.ok) throw new Error('请求失败')
|
||||
content.value = await res.text()
|
||||
} catch (e) {
|
||||
error.value = '用户协议加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agree-page pagebg">
|
||||
<div class="agree-box">
|
||||
<div class="header">
|
||||
<button class="back-btn" @click="router.back()">← 返回</button>
|
||||
<h2>用户协议及隐私协议</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-tip">加载中…</div>
|
||||
<div v-else-if="error" class="state-tip error">{{ error }}</div>
|
||||
<div v-else class="content" v-html="content" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agree-page {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.agree-box {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
color: #ddd;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
h2 {
|
||||
color: #f5bd10;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #f5bd10;
|
||||
color: #f5bd10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.state-tip {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 15px;
|
||||
|
||||
&.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
|
||||
:deep(h1), :deep(h2), :deep(h3) {
|
||||
color: #f5bd10;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #f5bd10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,77 @@
|
||||
<script setup>
|
||||
import Loading from "../components/loading.vue";
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import Loading from '../components/loading.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const error = ref('')
|
||||
const ready = ref(false)
|
||||
|
||||
/**
|
||||
* 解析 JWT payload(无需依赖 jwt 库,仅用于读取 username/server_id)
|
||||
*/
|
||||
function parseJwt(token) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1]
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return JSON.parse(decodeURIComponent(escape(atob(base64))))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const token = sessionStorage.getItem('CQ-TOKEN')
|
||||
if (!token) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 JWT 获取账号信息
|
||||
const payload = parseJwt(token)
|
||||
if (!payload?.username) {
|
||||
sessionStorage.removeItem('CQ-TOKEN')
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const account = payload.username
|
||||
const password = payload.password // md5 密码哈希,用于游戏客户端鉴权
|
||||
const serverId = payload.server_id || 1
|
||||
|
||||
// 调用 enter_game 接口,更新登录信息
|
||||
try {
|
||||
await request.post('/api/enter_game', {
|
||||
account,
|
||||
srvId: serverId,
|
||||
})
|
||||
} catch {
|
||||
// enter_game 失败不阻断进入游戏
|
||||
}
|
||||
|
||||
// 将账号/token/区服信息挂载到全局,供 Egret 游戏引擎读取
|
||||
window.__CQ_ACCOUNT__ = account
|
||||
window.__CQ_TOKEN__ = password
|
||||
window.__CQ_SERVER_ID__ = serverId
|
||||
|
||||
// 兼容旧版游戏引擎读取方式(通过 localStorage / cookie)
|
||||
try {
|
||||
localStorage.setItem('account', account)
|
||||
localStorage.setItem('token', password)
|
||||
localStorage.setItem('serverId', String(serverId))
|
||||
} catch {}
|
||||
|
||||
ready.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<loading/>
|
||||
<div v-if="error" style="color:#f00;text-align:center;padding-top:40vh;font-size:18px;">
|
||||
{{ error }}
|
||||
<br />
|
||||
<a style="color:#f5bd10;cursor:pointer" @click="$router.replace('/login')">重新登录</a>
|
||||
</div>
|
||||
<Loading v-else />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
170
module/web/src/views/linuxdo-bind.vue
Normal file
170
module/web/src/views/linuxdo-bind.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const connectId = ref('')
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
const submitting = ref(false)
|
||||
const bindMode = ref('register') // 'register' | 'link'
|
||||
|
||||
onMounted(() => {
|
||||
connectId.value = route.query.connect_id || ''
|
||||
if (!connectId.value) {
|
||||
ElMessage.error('缺少授权参数,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
|
||||
async function bindRegister() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/linuxdo/bind', {
|
||||
connect_id: connectId.value,
|
||||
action: 'register',
|
||||
})
|
||||
if (res.code === 0) {
|
||||
sessionStorage.setItem('CQ-TOKEN', res.token || '')
|
||||
ElMessage.success('绑定成功,正在进入游戏...')
|
||||
setTimeout(() => {
|
||||
window.location.href = `/play?account=${res.account}&token=${res.token}`
|
||||
}, 1200)
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bindLink() {
|
||||
if (!account.value) return ElMessage.error('请输入账号')
|
||||
if (!password.value) return ElMessage.error('请输入密码')
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/linuxdo/bind', {
|
||||
connect_id: connectId.value,
|
||||
account: account.value,
|
||||
password: password.value,
|
||||
action: 'link',
|
||||
})
|
||||
if (res.code === 0) {
|
||||
sessionStorage.setItem('CQ-TOKEN', res.token || '')
|
||||
ElMessage.success('绑定成功,正在进入游戏...')
|
||||
setTimeout(() => {
|
||||
window.location.href = `/play?account=${res.account}&token=${res.token}`
|
||||
}, 1200)
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="linuxdo-bind pagebg">
|
||||
<div class="bind-box">
|
||||
<h2>LinuxDo 账号绑定</h2>
|
||||
<p class="sub">LinuxDo账号:<strong>{{ connectId }}</strong></p>
|
||||
|
||||
<div class="tabs">
|
||||
<span :class="{ active: bindMode === 'register' }" @click="bindMode = 'register'">
|
||||
直接绑定(自动创建账号)
|
||||
</span>
|
||||
<span :class="{ active: bindMode === 'link' }" @click="bindMode = 'link'">
|
||||
绑定已有账号
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="bindMode === 'register'" class="tab-content">
|
||||
<p>将以 LinuxDo 账号名自动创建游戏账号并绑定。</p>
|
||||
<button :disabled="submitting" @click="bindRegister">
|
||||
{{ submitting ? '处理中…' : '确认绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="tab-content">
|
||||
<input v-model="account" type="text" placeholder="请输入游戏账号" autocomplete="off" />
|
||||
<input v-model="password" type="password" placeholder="请输入游戏密码" @keyup.enter="bindLink" />
|
||||
<button :disabled="submitting" @click="bindLink">
|
||||
{{ submitting ? '处理中…' : '绑定并登录' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.linuxdo-bind {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0a0a;
|
||||
|
||||
.bind-box {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
width: 360px;
|
||||
color: #fff;
|
||||
|
||||
h2 { text-align: center; color: #f5bd10; margin-bottom: 8px; }
|
||||
.sub { text-align: center; color: rgba(255,255,255,0.6); font-size: 13px; margin-bottom: 20px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
margin-bottom: 20px;
|
||||
span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.5);
|
||||
&.active { color: #f5bd10; border-bottom: 2px solid #f5bd10; }
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
p { font-size: 13px; color: rgba(255,255,255,0.6); }
|
||||
input {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
&::placeholder { color: rgba(255,255,255,0.35); }
|
||||
&:focus { border-color: #f5bd10; }
|
||||
}
|
||||
button {
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #f5bd10;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 4px;
|
||||
&:hover { opacity: 0.85; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,114 +1,573 @@
|
||||
<script setup>
|
||||
import {defineOptions, onMounted, ref} from "vue";
|
||||
import request from "@/utils/request";
|
||||
import {ElMessage} from "element-plus";
|
||||
import { defineOptions, onMounted, ref, computed, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
defineOptions({name: 'Login'});
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
const servers = ref([])
|
||||
const router = useRouter()
|
||||
|
||||
// 表单模式:0=登录 1=注册 2=找回密码
|
||||
const mode = ref(0)
|
||||
const modeName = computed(() => ({ 0: '登 录', 1: '注 册', 2: '修改密文' }[mode.value]))
|
||||
|
||||
// 表单字段
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
const password2 = ref('')
|
||||
const email = ref('')
|
||||
const code = ref('')
|
||||
const serverId = ref('')
|
||||
const agree = ref(false)
|
||||
const srvId = ref('')
|
||||
|
||||
// 区服列表
|
||||
const servers = ref([])
|
||||
// 验证码倒计时
|
||||
const codeCountdown = ref(0)
|
||||
let countdownTimer = null
|
||||
// 提交中
|
||||
const submitting = ref(false)
|
||||
|
||||
// 游戏配置(从后端获取)
|
||||
const gameConfig = ref({
|
||||
gameName: '清渊传奇',
|
||||
codeOpen: false,
|
||||
regCodeOpen: false,
|
||||
linuxdoAuthorizeUrl: '/api/linuxdo/authorize',
|
||||
})
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await request.get('/api/config')
|
||||
if (res?.data) gameConfig.value = { ...gameConfig.value, ...res.data }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const res = await request.get('/api/server/list')
|
||||
if (res?.code === 0) {
|
||||
const list = res.serverlist?.[0]?.serverlist || []
|
||||
servers.value = list
|
||||
if (list.length > 0) serverId.value = list[0].srvid
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── 登录 ─────────────────────────────────────────────────────────────────────
|
||||
async function handleLogin() {
|
||||
if (!agree.value) return ElMessage.error('请勾选同意用户协议')
|
||||
const {data} = await request.post('/api/login', {
|
||||
username: account,
|
||||
password
|
||||
})
|
||||
sessionStorage.setItem("CQ-TOKEN", data.token)
|
||||
if (!account.value) return ElMessage.error('请输入账号')
|
||||
if (!password.value) return ElMessage.error('请输入密码')
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/login', { username: account.value, password: password.value })
|
||||
if (res.code === 0) {
|
||||
sessionStorage.setItem('CQ-TOKEN', res.token)
|
||||
ElMessage.success(res.message || '登录成功')
|
||||
setTimeout(() => router.push('/'), 1500)
|
||||
} else {
|
||||
ElMessage.error(res.message || '登录失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getServers() {
|
||||
const {data} = await request.get('/api/server/list')
|
||||
servers.value = data
|
||||
// ─── 注册 ─────────────────────────────────────────────────────────────────────
|
||||
async function handleRegister() {
|
||||
if (!agree.value) return ElMessage.error('请勾选同意用户协议')
|
||||
if (!account.value) return ElMessage.error('请输入账号')
|
||||
if (!password.value) return ElMessage.error('请输入密码')
|
||||
if (!password2.value) return ElMessage.error('请再次输入密码')
|
||||
if (password.value !== password2.value) return ElMessage.error('两次密码不一致!')
|
||||
if (!serverId.value) return ElMessage.error('请选择区服!')
|
||||
if (gameConfig.value.codeOpen && gameConfig.value.regCodeOpen) {
|
||||
if (!email.value) return ElMessage.error('请输入邮箱地址!')
|
||||
if (!code.value) return ElMessage.error('请输入验证码!')
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/register', {
|
||||
username: account.value,
|
||||
password: password.value,
|
||||
password2: password2.value,
|
||||
serverId: serverId.value,
|
||||
email: email.value,
|
||||
code: code.value,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessageBox.alert(res.message, '注册成功', { type: 'success', confirmButtonText: '开始游戏' })
|
||||
.then(() => {
|
||||
sessionStorage.setItem('CQ-TOKEN', res.token)
|
||||
router.push('/')
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res.message || '注册失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function protectProtocol() {
|
||||
const agree = document.getElementById('agree')
|
||||
const agreeBtn = document.querySelector('#agree .agree_btn')
|
||||
agree.addEventListener('click', function () {
|
||||
agreeBtn.classList.toggle('agree_btn_checked')
|
||||
})
|
||||
// ─── 找回密码 ──────────────────────────────────────────────────────────────────
|
||||
async function handleResetPassword() {
|
||||
if (!account.value) return ElMessage.error('请输入账号')
|
||||
if (!email.value) return ElMessage.error('请输入邮箱地址')
|
||||
if (!password.value) return ElMessage.error('请输入新密码')
|
||||
if (!password2.value) return ElMessage.error('请再次输入新密码')
|
||||
if (password.value !== password2.value) return ElMessage.error('两次密码不一致!')
|
||||
if (!code.value) return ElMessage.error('请输入验证码!')
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/reset_password', {
|
||||
username: account.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
password2: password2.value,
|
||||
code: code.value,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.message || '密码修改成功!')
|
||||
switchMode(0)
|
||||
} else {
|
||||
ElMessage.error(res.message || '操作失败')
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 发送验证码 ────────────────────────────────────────────────────────────────
|
||||
async function sendCode() {
|
||||
if (codeCountdown.value > 0) return
|
||||
if (!account.value) return ElMessage.error('请先输入账号')
|
||||
if (!email.value) return ElMessage.error('请先输入邮箱地址')
|
||||
try {
|
||||
const res = await request.post('/api/send_code', {
|
||||
username: account.value,
|
||||
email: email.value,
|
||||
type: mode.value === 1 ? 1 : 2,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessage.success(res.message)
|
||||
codeCountdown.value = res.time || 60
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) clearInterval(countdownTimer)
|
||||
}, 1000)
|
||||
} else {
|
||||
ElMessage.error(res.message)
|
||||
if (res.time) {
|
||||
codeCountdown.value = res.time
|
||||
countdownTimer = setInterval(() => {
|
||||
codeCountdown.value--
|
||||
if (codeCountdown.value <= 0) clearInterval(countdownTimer)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── 提交总入口 ────────────────────────────────────────────────────────────────
|
||||
function handleSubmit() {
|
||||
if (mode.value === 0) handleLogin()
|
||||
else if (mode.value === 1) handleRegister()
|
||||
else handleResetPassword()
|
||||
}
|
||||
|
||||
// ─── 切换模式 ──────────────────────────────────────────────────────────────────
|
||||
function switchMode(m) {
|
||||
mode.value = m
|
||||
password.value = ''
|
||||
password2.value = ''
|
||||
code.value = ''
|
||||
if (m !== 1) email.value = ''
|
||||
}
|
||||
|
||||
// ─── 查看用户协议 ──────────────────────────────────────────────────────────────
|
||||
async function showProtocol() {
|
||||
try {
|
||||
const res = await fetch('/api/misc/agree')
|
||||
const html = await res.text()
|
||||
ElMessageBox({
|
||||
title: '用户协议及隐私协议',
|
||||
message: `<div style="max-height:60vh;overflow:auto;font-size:12px;">${html}</div>`,
|
||||
dangerouslyUseHTMLString: true,
|
||||
showCancelButton: false,
|
||||
confirmButtonText: '我已阅读',
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── LinuxDo 登录 ──────────────────────────────────────────────────────────────
|
||||
function loginWithLinuxDo() {
|
||||
window.location.href = gameConfig.value.linuxdoAuthorizeUrl
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getServers()
|
||||
loadConfig()
|
||||
loadServers()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(countdownTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login wrapper pagebg">
|
||||
<div class="loginBox flex column p-8 gap-8" id="account-login">
|
||||
<b class="title mb-20">神临苍月</b>
|
||||
<input class="w100" type="text" id="account" v-model="account" placeholder="请输入账号" @keyup="v=>account=v.replace(/[\W]/g, '')" autocomplete="off"/>
|
||||
<input class="w100" type="password" id="password" v-model="password" placeholder="请输入密码"/>
|
||||
<el-select id="serverId" v-model="srvId" style="border: none; display: none; margin-bottom: 10px;">
|
||||
<option value="0">请选择区服</option>
|
||||
<option v-for="item in servers" :value="item.id">{{ item.name }}区</option>
|
||||
</el-select>
|
||||
<div id="agree" class="agree flex gap-4">
|
||||
<el-checkbox v-model="agree"/>
|
||||
我已阅读并同意 <a @click="protectProtocol">用户协议及隐私协议</a>
|
||||
<div class="login">
|
||||
<div class="loginBox" id="account-login">
|
||||
<b class="title">{{ gameConfig.gameName }}</b>
|
||||
|
||||
<!-- 账号 -->
|
||||
<input
|
||||
class="w100"
|
||||
type="text"
|
||||
v-model="account"
|
||||
placeholder="请输入账号"
|
||||
@input="account = account.replace(/[^\w]/g, '')"
|
||||
autocomplete="off"
|
||||
maxlength="16"
|
||||
/>
|
||||
|
||||
<!-- 密码 -->
|
||||
<input
|
||||
class="w100"
|
||||
type="password"
|
||||
v-model="password"
|
||||
:placeholder="mode === 2 ? '请输入新密码' : '请输入密码'"
|
||||
maxlength="16"
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
|
||||
<!-- 确认密码(注册/找回密码) -->
|
||||
<input
|
||||
v-if="mode !== 0"
|
||||
class="w100"
|
||||
type="password"
|
||||
v-model="password2"
|
||||
:placeholder="mode === 2 ? '请再次输入新密码' : '请再次输入密码'"
|
||||
maxlength="16"
|
||||
/>
|
||||
|
||||
<!-- 区服选择(注册) -->
|
||||
<select v-if="mode === 1" v-model="serverId" class="w100 server-select">
|
||||
<option value="">请选择区服</option>
|
||||
<option v-for="item in servers" :key="item.srvid" :value="item.srvid">
|
||||
{{ item.serverName }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- 邮箱(注册+验证码开启 / 找回密码) -->
|
||||
<input
|
||||
v-if="(mode === 1 && gameConfig.codeOpen) || mode === 2"
|
||||
class="w100"
|
||||
type="email"
|
||||
v-model="email"
|
||||
:placeholder="mode === 2 ? '请输入绑定的邮箱地址' : '邮箱地址(用于找回密码,选填)'"
|
||||
/>
|
||||
|
||||
<!-- 验证码(注册+强制验证 / 找回密码) -->
|
||||
<div
|
||||
v-if="(mode === 1 && gameConfig.codeOpen && gameConfig.regCodeOpen) || mode === 2"
|
||||
class="code-row"
|
||||
>
|
||||
<input class="flex-1" type="text" v-model="code" placeholder="请输入验证码" maxlength="6" />
|
||||
<button class="code-btn" :disabled="codeCountdown > 0" @click="sendCode">
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s 后重发` : '获取验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="button pointer w100 py-8" @click="handleLogin">登 录</div>
|
||||
<div style="display:flex;justify-content:center;gap:8px;font-size:12px">
|
||||
<div style="display:flex;align-items:center;flex-direction:column;gap:4px;cursor:pointer" id="linuxdoConnect">
|
||||
<img src="/img/linuxdo_logo.png" style="width:60px;height:60px" alt="Linux.Do登录"/>
|
||||
<div>Linux.do</div>
|
||||
|
||||
<!-- 同意协议(登录/注册) -->
|
||||
<div v-if="mode !== 2" class="agree">
|
||||
<el-checkbox v-model="agree" />
|
||||
<span>我已阅读并同意 <a class="link" @click="showProtocol">用户协议及隐私协议</a></span>
|
||||
</div>
|
||||
|
||||
<!-- 主按钮 -->
|
||||
<div
|
||||
class="button"
|
||||
:class="{ disabled: submitting }"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ submitting ? '处理中…' : modeName }}
|
||||
</div>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<div v-if="mode === 0" class="third-login">
|
||||
<div class="third-item" @click="loginWithLinuxDo">
|
||||
<img src="/img/linuxdo_logo.png" alt="Linux.Do 登录" />
|
||||
<div>Linux.Do</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="forget_password">
|
||||
<a href="javascript:void(0);" id="forgetPassword" data-type="2">忘记密码?</a>
|
||||
<a href="javascript:void(0);" class="pull-right" id="switchBtn" data-type="1">注册</a>
|
||||
|
||||
<!-- 底部切换 -->
|
||||
<div class="footer-links">
|
||||
<template v-if="mode === 0">
|
||||
<a class="link" @click="switchMode(2)">忘记密码?</a>
|
||||
<a class="link" @click="switchMode(1)">注册</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a class="link" @click="switchMode(0)">返回登录</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bg" class="gamebg"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ── 页面容器 ─────────────────────────────────────────────────── */
|
||||
.login {
|
||||
position: relative;
|
||||
background-image: url("/img/login_bg.jpg");
|
||||
background-image: url('/img/login_bg.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh; /* 兼容移动端动态视口 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.loginBox {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(4px);
|
||||
/* ── 登录卡片 ─────────────────────────────────────────────────── */
|
||||
.loginBox {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: 14px;
|
||||
padding: 28px 24px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
& > .title {
|
||||
font-size: 28px
|
||||
/* 手机竖屏:紧贴两侧,去掉多余圆角 */
|
||||
@media (max-width: 480px) {
|
||||
max-width: 100%;
|
||||
padding: 20px 16px;
|
||||
border-radius: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 标题 ─────────────────────────────────────────────────────── */
|
||||
.title {
|
||||
font-size: 26px;
|
||||
color: #f5bd10;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 2px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 表单元素 ─────────────────────────────────────────────────── */
|
||||
input, select {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 15px; /* 防止 iOS Safari 自动缩放(< 16px 会触发) */
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #f5bd10;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
option {
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
height: 44px; /* 更大的点击区域 */
|
||||
}
|
||||
}
|
||||
|
||||
.server-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 验证码行 ──────────────────────────────────────────────────── */
|
||||
.code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 14px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f5bd10;
|
||||
background: transparent;
|
||||
color: #f5bd10;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: rgb(245, 189, 16);
|
||||
text-align: center;
|
||||
letter-spacing: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: .8
|
||||
}
|
||||
&:not(:disabled):hover {
|
||||
background: rgba(245, 189, 16, 0.15);
|
||||
}
|
||||
|
||||
.agree_btn {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
& > input {
|
||||
height: 32px;
|
||||
@media (max-width: 480px) {
|
||||
height: 44px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 协议勾选 ──────────────────────────────────────────────────── */
|
||||
.agree {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
.link {
|
||||
color: #f5bd10;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 主按钮 ────────────────────────────────────────────────────── */
|
||||
.button {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
background: #f5bd10;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
line-height: 46px;
|
||||
letter-spacing: 6px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
transition: opacity 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 第三方登录 ────────────────────────────────────────────────── */
|
||||
.third-login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
|
||||
.third-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover, &:active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 10px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 底部链接 ──────────────────────────────────────────────────── */
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
|
||||
.link {
|
||||
color: #f5bd10;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 通用工具类 ────────────────────────────────────────────────── */
|
||||
.flex { display: flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
.column { flex-direction: column; }
|
||||
.align-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.gap-4 { gap: 4px; }
|
||||
.gap-8 { gap: 8px; }
|
||||
.w100 { width: 100%; }
|
||||
.mb-20 { margin-bottom: 4px; }
|
||||
.p-8 { padding: 8px; }
|
||||
.py-8 { padding-top: 10px; padding-bottom: 10px; }
|
||||
.pointer { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
400
module/web/src/views/withdraw.vue
Normal file
400
module/web/src/views/withdraw.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '@/utils/request'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 账号信息(从 JWT 解析)
|
||||
const currentAccount = ref('')
|
||||
const currentServerId = ref(1)
|
||||
|
||||
// 表单
|
||||
const serverId = ref('')
|
||||
const roleId = ref('')
|
||||
const roleName = ref('')
|
||||
const payType = ref(0) // 0=支付宝 1=微信
|
||||
const payAccount = ref('')
|
||||
const amount = ref('')
|
||||
|
||||
// 区服列表
|
||||
const servers = ref([])
|
||||
|
||||
// 游戏配置
|
||||
const gameConfig = ref({})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// 计算兑换金额
|
||||
const money = computed(() => {
|
||||
const ratio = gameConfig.value?.withdrawRatio || 10000
|
||||
const a = parseInt(amount.value)
|
||||
if (!a || a < ratio) return 0
|
||||
return Math.floor(a / ratio)
|
||||
})
|
||||
|
||||
const currencyName = computed(() => gameConfig.value?.currencyName || '货币')
|
||||
|
||||
/**
|
||||
* 解析 JWT payload
|
||||
*/
|
||||
function parseJwt(token) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1]
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return JSON.parse(decodeURIComponent(escape(atob(base64))))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await request.get('/api/config')
|
||||
if (res?.data) {
|
||||
gameConfig.value = {
|
||||
...gameConfig.value,
|
||||
gameName: res.data.gameName,
|
||||
withdrawRatio: res.data.withdrawRatio || 10000,
|
||||
withdrawMinOnce: res.data.withdrawMinOnce || 20,
|
||||
currencyName: res.data.currencyName || '货币',
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadServers() {
|
||||
try {
|
||||
const res = await request.get('/api/server/list')
|
||||
if (res?.code === 0) {
|
||||
servers.value = res.serverlist?.[0]?.serverlist || []
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleWithdraw() {
|
||||
if (!serverId.value) return ElMessage.error('请选择区服')
|
||||
if (!roleId.value) return ElMessage.error('请输入角色 ID')
|
||||
if (!roleName.value) return ElMessage.error('请输入角色名')
|
||||
if (!payAccount.value) return ElMessage.error('请输入收款账户')
|
||||
const a = parseInt(amount.value)
|
||||
if (!a || a <= 0) return ElMessage.error('请输入提现数量')
|
||||
const ratio = gameConfig.value?.withdrawRatio || 10000
|
||||
const minOnce = gameConfig.value?.withdrawMinOnce || 20
|
||||
if (a < ratio * minOnce) return ElMessage.error(`单次提现数量不能低于 ${ratio * minOnce}`)
|
||||
|
||||
const payTypeName = payType.value === 0 ? '支付宝' : '微信'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认提现 ${a} ${currencyName.value},折合人民币 ${money.value} 元?\n收款方式:${payTypeName}(${payAccount.value})`,
|
||||
'确认提现',
|
||||
{ confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await request.post('/api/game/withdraw', {
|
||||
server_id: 's' + serverId.value,
|
||||
account: currentAccount.value,
|
||||
role_id: roleId.value,
|
||||
role_name: roleName.value,
|
||||
pay_type: payType.value,
|
||||
pay_account: payAccount.value,
|
||||
amount: a,
|
||||
})
|
||||
if (res.code === 0) {
|
||||
ElMessageBox.alert(res.message, '提现成功', { type: 'success', confirmButtonText: '知道了' })
|
||||
amount.value = ''
|
||||
} else {
|
||||
ElMessage.error(res.message || '提现失败')
|
||||
}
|
||||
} catch {} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const token = sessionStorage.getItem('CQ-TOKEN')
|
||||
if (!token) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
const payload = parseJwt(token)
|
||||
if (payload?.username) {
|
||||
currentAccount.value = payload.username
|
||||
currentServerId.value = payload.server_id || 1
|
||||
serverId.value = currentServerId.value
|
||||
}
|
||||
|
||||
await Promise.all([loadConfig(), loadServers()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="withdraw-page pagebg">
|
||||
<div class="withdraw-box">
|
||||
<div class="header">
|
||||
<button class="back-btn" @click="router.back()">← 返回</button>
|
||||
<h2>游戏提现</h2>
|
||||
</div>
|
||||
|
||||
<div class="account-info">
|
||||
当前账号:<strong>{{ currentAccount }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<!-- 区服 -->
|
||||
<div class="form-item">
|
||||
<label>选择区服</label>
|
||||
<select v-model="serverId">
|
||||
<option value="">请选择区服</option>
|
||||
<option v-for="s in servers" :key="s.srvid" :value="s.srvid">
|
||||
{{ s.serverName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 角色 ID -->
|
||||
<div class="form-item">
|
||||
<label>角色 ID</label>
|
||||
<input
|
||||
v-model="roleId"
|
||||
type="number"
|
||||
placeholder="请输入游戏内角色 ID"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 角色名 -->
|
||||
<div class="form-item">
|
||||
<label>角色名</label>
|
||||
<input
|
||||
v-model="roleName"
|
||||
type="text"
|
||||
placeholder="请输入游戏内角色名"
|
||||
maxlength="24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 提现数量 -->
|
||||
<div class="form-item">
|
||||
<label>提现数量({{ currencyName }})</label>
|
||||
<input
|
||||
v-model="amount"
|
||||
type="number"
|
||||
:placeholder="`最少 ${(gameConfig.withdrawRatio || 10000) * (gameConfig.withdrawMinOnce || 20)} ${currencyName}`"
|
||||
min="0"
|
||||
/>
|
||||
<div v-if="money > 0" class="hint">≈ 人民币 {{ money }} 元</div>
|
||||
</div>
|
||||
|
||||
<!-- 收款方式 -->
|
||||
<div class="form-item">
|
||||
<label>收款方式</label>
|
||||
<div class="pay-type-row">
|
||||
<label class="radio-label" :class="{ active: payType === 0 }" @click="payType = 0">
|
||||
支付宝
|
||||
</label>
|
||||
<label class="radio-label" :class="{ active: payType === 1 }" @click="payType = 1">
|
||||
微信
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收款账户 -->
|
||||
<div class="form-item">
|
||||
<label>{{ payType === 0 ? '支付宝账号' : '微信号' }}</label>
|
||||
<input
|
||||
v-model="payAccount"
|
||||
type="text"
|
||||
:placeholder="payType === 0 ? '请输入支付宝账号(手机号/邮箱)' : '请输入微信号'"
|
||||
maxlength="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tip">
|
||||
提示:提现到账时间为工作日,如有疑问请联系客服。
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="submitting"
|
||||
@click="handleWithdraw"
|
||||
>
|
||||
{{ submitting ? '处理中…' : '申请提现' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.withdraw-page {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.withdraw-box {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
color: #ddd;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
h2 {
|
||||
color: #f5bd10;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
border-color: #f5bd10;
|
||||
color: #f5bd10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.account-info {
|
||||
background: rgba(245, 189, 16, 0.08);
|
||||
border: 1px solid rgba(245, 189, 16, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
strong {
|
||||
color: #f5bd10;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
input, select {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #f5bd10;
|
||||
}
|
||||
|
||||
option {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #f5bd10;
|
||||
}
|
||||
|
||||
.pay-type-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.radio-label {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
border-color: #f5bd10;
|
||||
color: #f5bd10;
|
||||
background: rgba(245, 189, 16, 0.08);
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid rgba(245, 189, 16, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #f5bd10;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
letter-spacing: 4px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,22 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
// 构建目标:现代浏览器
|
||||
target: 'es2020',
|
||||
// chunk 大小警告阈值(kB)
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 将大型依赖拆分为独立 chunk,利用浏览器缓存
|
||||
manualChunks: {
|
||||
'vendor-vue': ['vue', 'vue-router'],
|
||||
'vendor-element': ['element-plus'],
|
||||
'vendor-axios': ['axios'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
nginx.conf.example
Normal file
104
nginx.conf.example
Normal file
@@ -0,0 +1,104 @@
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 清渊传奇 H5 游戏平台 — Nginx 反向代理配置
|
||||
# 路径:/etc/nginx/conf.d/chuanqi.conf
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# 架构说明:
|
||||
# ┌─────────────────────────────────────────┐
|
||||
# │ 浏览器 │
|
||||
# │ ↓ HTTPS :443 / HTTP :80 │
|
||||
# │ Nginx │
|
||||
# │ ├── /api/* → Node.js :3001 │
|
||||
# │ ├── /public/* → 静态文件(Egret) │
|
||||
# │ └── 其他 → Vue dist 目录 │
|
||||
# └─────────────────────────────────────────┘
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
upstream chuanqi_api {
|
||||
server 127.0.0.1:3001;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# ─── HTTP → HTTPS 重定向 ──────────────────────────────────────────
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# Let's Encrypt / ACME 验证(如使用)
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ─── HTTPS 主配置 ─────────────────────────────────────────────────
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
# ── SSL 证书(替换为实际路径)─────────────────────────────────
|
||||
ssl_certificate /etc/ssl/certs/your-domain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/your-domain.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# ── 基础配置 ──────────────────────────────────────────────────
|
||||
charset utf-8;
|
||||
client_max_body_size 20m;
|
||||
|
||||
# ── 1. API 请求 → Node.js ─────────────────────────────────────
|
||||
location /api/ {
|
||||
proxy_pass http://chuanqi_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
|
||||
# ── 2. Egret 游戏静态资源(大量小文件,强缓存)──────────────
|
||||
location /public/ {
|
||||
alias /path/to/chuanqi-qycq-web/public/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ── 3. 游戏入口 js(每次重载)─────────────────────────────────
|
||||
location ~* ^/js/(index|loader|microclient)\.js$ {
|
||||
root /path/to/chuanqi-qycq-web;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store";
|
||||
}
|
||||
|
||||
# ── 4. 其他静态资源(图片/字体等,适度缓存)─────────────────
|
||||
location ~* \.(png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$ {
|
||||
root /path/to/chuanqi-qycq-web/module/web/dist;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ── 5. Vue 前端(dist,HTML5 History 模式)───────────────────
|
||||
location / {
|
||||
root /path/to/chuanqi-qycq-web/module/web/dist;
|
||||
index index.html;
|
||||
# SPA 路由回退
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# ── 日志 ──────────────────────────────────────────────────────
|
||||
access_log /var/log/nginx/chuanqi_access.log;
|
||||
error_log /var/log/nginx/chuanqi_error.log warn;
|
||||
}
|
||||
2396
pnpm-lock.yaml
generated
Normal file
2396
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user