🎉 feat: initialize 宇之然AI维度 project
- uni-app Vue3 frontend with dark glassmorphism theme - 5 AI dimensions: Origin / Development / Current / Learning / Trend - AI Chat system prompt updated from geometry to AI - 23 AI knowledge articles initialized in DB - Trend API + cron job for daily news - Product pricing (Pro ¥19.9, VIP ¥39.9) - Pinia stores + API utilities - AGENTS.md documentation
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
logs/
|
||||
*.log
|
||||
.DS_Store
|
||||
Executable
+263
@@ -0,0 +1,263 @@
|
||||
# 宇之然·星辰绘线 - 后端服务
|
||||
|
||||
## 项目简介
|
||||
|
||||
这是宇之然·星辰绘线项目的后端服务,提供 RESTful API 接口,支持微信小程序和后台管理系统。
|
||||
# 1. 查找占用指定端口的进程ID
|
||||
netstat -tunlp | grep :3001
|
||||
# 2. 根据进程ID kill进程
|
||||
kill -9 进程ID
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**: Node.js 18+
|
||||
- **框架**: Express.js 4.18
|
||||
- **数据库**: MongoDB (Mongoose ODM)
|
||||
- **认证**: JWT
|
||||
- **安全**: Helmet, CORS, Rate Limit
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
wdkj-server/
|
||||
├── src/
|
||||
│ ├── config/ # 配置文件
|
||||
│ │ └── database.js # 数据库连接
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ └── auth.js # 认证中间件
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── User.js # 用户模型
|
||||
│ │ ├── Order.js # 订单模型
|
||||
│ │ ├── Gallery.js # 画廊模型
|
||||
│ │ ├── Knowledge.js # 知识库模型
|
||||
│ │ ├── ShopItem.js # 商品模型
|
||||
│ │ ├── Admin.js # 管理员模型
|
||||
│ │ └── AdminLog.js # 操作日志模型
|
||||
│ ├── routes/ # 路由
|
||||
│ │ ├── auth.js # 认证路由
|
||||
│ │ ├── user.js # 用户路由
|
||||
│ │ ├── payment.js # 支付路由
|
||||
│ │ ├── gallery.js # 画廊路由
|
||||
│ │ ├── knowledge.js # 知识库路由
|
||||
│ │ ├── admin.js # 管理后台路由
|
||||
│ │ └── index.js # 路由汇总
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── response.js # 统一响应
|
||||
│ │ ├── logger.js # 日志工具
|
||||
│ │ └── weixin.js # 微信API工具
|
||||
│ └── index.js # 主入口
|
||||
├── logs/ # 日志目录
|
||||
├── .env # 环境变量
|
||||
├── package.json # 项目配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd wdkj-server
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env` 并修改配置:
|
||||
|
||||
```env
|
||||
# 服务配置
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# MongoDB 配置
|
||||
MONGODB_URI=mongodb://wdkj123:wdkj123@192.168.136.130:27017/wdkj?authSource=wdkj
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your_secret_key_here
|
||||
JWT_EXPIRE=7d
|
||||
|
||||
# 微信小程序配置
|
||||
WX_APPID=wxdad62baf4ccd09e3
|
||||
WX_SECRET=your_wx_app_secret
|
||||
|
||||
# 微信支付配置
|
||||
WX_PAY_MCH_ID=1108945993
|
||||
WX_PAY_API_KEY=your_apiv3_key
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
npm run dev
|
||||
|
||||
# 生产环境
|
||||
npm start
|
||||
```
|
||||
|
||||
### 4. 验证服务
|
||||
|
||||
访问健康检查接口:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 认证相关
|
||||
|
||||
- `POST /api/auth/login` - 小程序登录
|
||||
- `POST /api/auth/admin/login` - 管理员登录
|
||||
- `GET /api/auth/me` - 获取当前用户信息
|
||||
|
||||
### 用户相关
|
||||
|
||||
- `GET /api/user/progress` - 获取用户进度
|
||||
- `POST /api/user/progress` - 保存用户进度
|
||||
- `GET /api/user/leaderboard` - 获取排行榜
|
||||
|
||||
### 支付相关
|
||||
|
||||
- `POST /api/payment/create-order` - 创建订单
|
||||
- `POST /api/payment/callback` - 支付回调
|
||||
- `POST /api/payment/verify` - 验证支付
|
||||
- `GET /api/payment/products` - 获取商品列表
|
||||
|
||||
### 画廊相关
|
||||
|
||||
- `GET /api/gallery` - 获取作品列表
|
||||
- `POST /api/gallery` - 上传作品
|
||||
- `GET /api/gallery/:id` - 获取作品详情
|
||||
- `POST /api/gallery/:id/like` - 点赞作品
|
||||
|
||||
### 知识库相关
|
||||
|
||||
- `GET /api/knowledge` - 获取知识列表
|
||||
- `GET /api/knowledge/:id` - 获取知识详情
|
||||
- `POST /api/knowledge/:id/collect` - 收藏知识
|
||||
- `GET /api/knowledge/:dim/questions` - 获取问答
|
||||
|
||||
### 管理后台
|
||||
|
||||
- `GET /api/admin/dashboard` - 获取仪表盘数据
|
||||
- `GET /api/admin/users` - 获取用户列表
|
||||
- `GET /api/admin/users/:id` - 获取用户详情
|
||||
- `GET /api/admin/knowledge` - 获取知识列表
|
||||
- `POST /api/admin/knowledge` - 创建/更新知识
|
||||
- `POST /api/admin/knowledge/review` - 审核知识
|
||||
- `GET /api/admin/gallery` - 获取画廊作品
|
||||
- `POST /api/admin/gallery/review` - 审核作品
|
||||
- `GET /api/admin/orders` - 获取订单列表
|
||||
- `GET /api/admin/shop` - 获取商品列表
|
||||
- `POST /api/admin/shop` - 保存商品
|
||||
- `GET /api/admin/logs` - 获取操作日志
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
首次启动服务时,需要初始化管理员账户:
|
||||
|
||||
```javascript
|
||||
// 可以创建一个初始化脚本
|
||||
const Admin = require('./src/models/Admin')
|
||||
|
||||
async function initAdmin() {
|
||||
const admin = new Admin({
|
||||
username: 'admin',
|
||||
password: 'admin123456',
|
||||
email: 'admin@wdkj.com',
|
||||
role: 'super_admin',
|
||||
permissions: ['*']
|
||||
})
|
||||
await admin.save()
|
||||
}
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 ES6+ 语法
|
||||
- 异步操作使用 async/await
|
||||
- 错误统一通过 ApiResponse 处理
|
||||
- 所有操作记录日志
|
||||
|
||||
### 日志系统
|
||||
|
||||
日志级别:
|
||||
- `debug`: 调试信息
|
||||
- `info`: 常规信息
|
||||
- `warn`: 警告信息
|
||||
- `error`: 错误信息
|
||||
|
||||
使用示例:
|
||||
|
||||
```javascript
|
||||
const logger = require('./utils/logger')
|
||||
|
||||
logger.info('用户登录', { openid: 'xxx' })
|
||||
logger.error('数据库错误', error)
|
||||
```
|
||||
|
||||
### 权限系统
|
||||
|
||||
角色权限矩阵:
|
||||
|
||||
| 角色 | 权限范围 |
|
||||
|------|----------|
|
||||
| super_admin | 所有权限 (*) |
|
||||
| content_manager | 知识库、画廊、用户查看 |
|
||||
| shop_manager | 商品、订单、用户查看 |
|
||||
| viewer | 仪表盘、分析、用户查看 |
|
||||
|
||||
## 生产部署
|
||||
|
||||
### 使用 PM2
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 启动服务
|
||||
pm2 start src/index.js --name wdkj-server
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
|
||||
# 查看日志
|
||||
pm2 logs wdkj-server
|
||||
```
|
||||
|
||||
### 使用 Docker
|
||||
|
||||
创建 `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
构建并运行:
|
||||
|
||||
```bash
|
||||
docker build -t wdkj-server .
|
||||
docker run -p 3000:3000 -d wdkj-server
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **安全**: 生产环境必须修改 JWT_SECRET
|
||||
2. **数据库**: 确保 MongoDB 已正确配置访问权限
|
||||
3. **支付**: 微信支付需要真实的商户证书和密钥
|
||||
4. **日志**: 生产环境会自动写入日志文件
|
||||
5. **限流**: 已配置 API 请求频率限制
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIENDCCAxygAwIBAgIUYUzuDMy950xrnuZnUV3V4gBjtKQwDQYJKoZIhvcNAQEL
|
||||
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||
Q0EwHhcNMjYwMzI0MDYzNTUxWhcNMzEwMzIzMDYzNTUxWjCBjTETMBEGA1UEAwwK
|
||||
MTEwODk0NTk5MzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTkwNwYDVQQL
|
||||
DDDljJfkuqzlrofkuYvnhLbnp5HmioDkuK3lv4PvvIjkuKrkvZPlt6XllYbmiLfv
|
||||
vIkxCzAJBgNVBAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcN
|
||||
AQEBBQADggEPADCCAQoCggEBANKG2DnPUqF4kP/IErd0z7MejTXhfRT/9shGgNUm
|
||||
SFQmK5Vb7owQyFORj5Y22r5Xa+g7t0Wjwmc8jjIFiIIUickf9IHedDzpclK6nPcf
|
||||
whgEQ5YolA0yRSujlBdpDKxiZED/OdiF+oRNW3Orl4dC2eb/+yCvjr9IgXRwnn4l
|
||||
ODP8DQ4/xW6JnQhyy+yQ4vydhkK1G1aaqS3kQAyQopgwcGif1e9Wo6JI1c+1MF98
|
||||
4KNLEVABg9sH2yRMYSsspyvJfdu+FA9oZT5LXGjSrdyaq7t8cESqRzhQMLvRcZ3o
|
||||
QSbY9EE9Xy0dt9URS7ZdK53MwoRpnQsUSwpWT8r07f9sM1ECAwEAAaOBuTCBtjAJ
|
||||
BgNVHRMEAjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGE
|
||||
aHR0cDovL2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0
|
||||
MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFC
|
||||
NjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEB
|
||||
CwUAA4IBAQCXQjHcCkJh/Wr3qvi5q3yBXA/zUchZOtBh220dGdEvR+L+9s9i7aGf
|
||||
legNT0+E1AakD/W6TC25pL7oK3QmyjdW+JGdNtfVh5v+uUNu333mRPre5Bj94cfJ
|
||||
9W7EHgfzEi3MJ3HXBclh7pCyxl6FRXXXj7STeBetVH0njvYRV6nOl7wobW77lE5N
|
||||
JkUYPVPPd5pbS3BSpykBS4zxtaBK2MtzhhELonAbzib2z0edlwlGoJD0BFAr7BGT
|
||||
00mM95Hz5I3W4UgHvv+mDlOMjBAwCtaDSSCoJDhiaxs/WEru4jSqY4aiKEsVrtAQ
|
||||
zcR9dNa21f+uzO6/oziswYstXOkzeVoc
|
||||
-----END CERTIFICATE-----
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDShtg5z1KheJD/
|
||||
yBK3dM+zHo014X0U//bIRoDVJkhUJiuVW+6MEMhTkY+WNtq+V2voO7dFo8JnPI4y
|
||||
BYiCFInJH/SB3nQ86XJSupz3H8IYBEOWKJQNMkUro5QXaQysYmRA/znYhfqETVtz
|
||||
q5eHQtnm//sgr46/SIF0cJ5+JTgz/A0OP8VuiZ0IcsvskOL8nYZCtRtWmqkt5EAM
|
||||
kKKYMHBon9XvVqOiSNXPtTBffOCjSxFQAYPbB9skTGErLKcryX3bvhQPaGU+S1xo
|
||||
0q3cmqu7fHBEqkc4UDC70XGd6EEm2PRBPV8tHbfVEUu2XSudzMKEaZ0LFEsKVk/K
|
||||
9O3/bDNRAgMBAAECggEBAJdPgZMzNmGFpTmhlAo1M566o6WJNnxkZ/uT6L7J8AxD
|
||||
Duofk/kpisG9ieTd8iQB8zWLzfKIe431kQHUxkMv/cQHapX4y7SH2LorE+vt8HOQ
|
||||
hP8klRxS+DIgNK0KvKgdY47voaTEzsROUR92wcbevnxvmQ/p/f/vXdPRqahimd2J
|
||||
yZhIizb+H/d9ua5co5icVcupknK+mBna5uDqa/hoYPNVXmvzF4I1bZFeylN3v9CE
|
||||
3YQt3qZ9OPPHl4zTQWkx7c2c+ELzOC9xWanUhfw41vtuv3hreZBZp9+jJpP5z9P7
|
||||
Rr1LCom7RtDNW0Py18p7GX3jAmjLoIehfqnIdjN0aU0CgYEA9ermlfZAP4W2MH+v
|
||||
t97xlXn6JWBC9Mwlu7xqEJlfrdk8c/z5krBOoc+11WZoUZdtMlho1R8l4qn4GWUG
|
||||
VbAQJm/M2kdMVTmDWnx/2DiCrqH07mjxf6UgX1kUwiBU4YZTI+qWSV9tzf7mXtpD
|
||||
mupOnRInA9urvGocUgThllaCXasCgYEA2yh9McFKrkUZ6v9HFXekLWTjpVV2xXYU
|
||||
4l7UJS8oMHMrIftP4O6EAvkOH6ZP1Ip61KQACfe0j0TiiXrLvpNXmxEe9gfaWTZY
|
||||
OjYeGy0sdJQ8ByXzsaguPDI20qMn56nluDRWgh9JqIUB5ZYSE84wh9hhEJdJEi0a
|
||||
uQVQusxZ3vMCgYBQcGa+dM8tOBKRKKMUtPM23rBJG4SrSD+regUDqveWCTHyCrSk
|
||||
G9GMskGbLSVAUxTf2/VmiQq+arSBsf7xdmbB+935JEs+sVJa/dBfrJRqhQV2GpOs
|
||||
GhpNtfjJSwQYxPRbEjfYhkOHlzJJooFsoenXDQIADBHPzrG2zmvv3hpEgwKBgDAd
|
||||
L38g5HhmC06gRMbdwVmF8MR3gt/PEL6x+vk+5R/d6PZI6jyDoM/WRdeE97m9vUG5
|
||||
z5hgnW9mvCVapChuyKjulVGGX9V+LNh60Rk+Tag6cwNOvbWfKqdkjsynrTcbAafz
|
||||
NZrTq6Qnr0bsL4PRnK32PzkZPZ2T8W31UMfx3TkpAoGBANuTZsojCvyL1ZfcB4P8
|
||||
Cf+Xv6CU5165DE+q+Sj3/HDrmDnDD9J4B0TlXj1803dydz4ZsC2BJqmCDXQGFI09
|
||||
FbQV3AIGE4XI2GWIC9f4+0W1FfAUp0Y2I5p7OMl4eN7RKh0PHCajN5pM534cZ0k/
|
||||
TQHNwOuaI56juLP4TIMKDPGk
|
||||
-----END PRIVATE KEY-----
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzK+YibPERKTi8CaO+Yq
|
||||
meTdJmaTqHTS/XBbnRmZgQmudwPIfybp1E+wPKixcBrw/qaj6Ewfoo+6eh++aUVw
|
||||
ImiQcmYrF+4IgpaTJMfYwYgyeig8XlGlwNNngWPZ7g+7Q7FZUjMYJ4ITq2IHCyWR
|
||||
IhQfPY9auZdBAndtcX9flDdUAzur2EDYVNYMlFMxt2wGnBGfXitsZefWdvRq5IA1
|
||||
N8zCxfCRvN9GFBjtjQXKeUmPwxLmcqKuA3fRz/THjq0oeggQK9PNQmtZgUD8nKE7
|
||||
cils+CFpkLK+a34iFIA2QHygL6itReyV7O47MD4gYYW+aCmp/L0FUcdISJtaCZ2T
|
||||
9QIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.js',
|
||||
'!src/index.js'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testTimeout: 30000,
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
||||
verbose: true
|
||||
}
|
||||
+5763
File diff suppressed because it is too large
Load Diff
Executable
+45
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "wdkj-server",
|
||||
"version": "1.0.0",
|
||||
"description": "宇之然-空间探索 - 后端服务",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"miniprogram",
|
||||
"geometry",
|
||||
"education"
|
||||
],
|
||||
"author": "宇之然团队",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^7.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo 运行单元测试和集成测试
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd %~dp0
|
||||
|
||||
echo [1/3] 设置测试环境...
|
||||
set NODE_ENV=test
|
||||
|
||||
echo [2/3] 运行测试...
|
||||
call npm test
|
||||
|
||||
echo.
|
||||
echo [3/3] 生成测试报告...
|
||||
echo 测试完成!
|
||||
|
||||
pause
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
echo ========================================
|
||||
echo MongoDB 用户创建工具
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 请在 MongoDB 服务器上执行以下命令:
|
||||
echo.
|
||||
echo 1. 连接到 MongoDB:
|
||||
echo mongo --host 192.168.136.130 --port 27017
|
||||
echo.
|
||||
echo 2. 切换到 admin 数据库:
|
||||
echo use admin
|
||||
echo.
|
||||
echo 3. 创建管理员用户(如果还没有):
|
||||
echo db.createUser({
|
||||
echo user: "admin",
|
||||
echo pwd: "admin123",
|
||||
echo roles: ["root"]
|
||||
echo })
|
||||
echo.
|
||||
echo 4. 切换到 wdkj 数据库:
|
||||
echo use wdkj
|
||||
echo.
|
||||
echo 5. 创建 wdkj 用户:
|
||||
echo db.createUser({
|
||||
echo user: "wdkj123",
|
||||
echo pwd: "wdkj123",
|
||||
echo roles: [
|
||||
echo { role: "readWrite", db: "wdkj" }
|
||||
echo ]
|
||||
echo })
|
||||
echo.
|
||||
echo 6. 验证用户:
|
||||
echo db.auth("wdkj123", "wdkj123")
|
||||
echo.
|
||||
echo ========================================
|
||||
pause
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 快速修复BGM URL - 使用测试音频
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config()
|
||||
|
||||
const { BGM } = require('../src/models')
|
||||
|
||||
// 免费的测试音频URL (SoundHelix - 可商用)
|
||||
const TEST_AUDIO_URL = 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
|
||||
|
||||
async function fixBGMUrls() {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/yuzhiran')
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 更新所有BGM的URL为测试音频
|
||||
const result = await BGM.updateMany(
|
||||
{},
|
||||
{ url: TEST_AUDIO_URL }
|
||||
)
|
||||
|
||||
console.log(`✅ 已更新 ${result.modifiedCount} 条BGM记录`)
|
||||
console.log(` 新URL: ${TEST_AUDIO_URL}`)
|
||||
console.log('\n💡 现在可以测试BGM播放功能了')
|
||||
console.log('⚠️ 注意: 这是测试音频,生产环境请替换为正式音乐')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 更新失败:', error)
|
||||
} finally {
|
||||
await mongoose.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fixBGMUrls()
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* BGM音效初始化脚本
|
||||
* 为每个维度添加示例背景音乐
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config()
|
||||
|
||||
// 导入模型
|
||||
const { BGM } = require('../src/models')
|
||||
|
||||
// 示例BGM数据(实际使用时需要替换为真实的音频URL)
|
||||
const sampleBGMs = [
|
||||
// 一维 - 线性旋律
|
||||
{
|
||||
name: '星光轨迹',
|
||||
dimension: 1,
|
||||
url: 'https://example.com/bgm/dim1_starlight.mp3',
|
||||
duration: 120,
|
||||
loop: true,
|
||||
volume: 0.4,
|
||||
sortOrder: 1,
|
||||
description: '轻柔的线性旋律,适合一维空间的探索',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '弦之舞',
|
||||
dimension: 1,
|
||||
url: 'https://example.com/bgm/dim1_string_dance.mp3',
|
||||
duration: 95,
|
||||
loop: true,
|
||||
volume: 0.45,
|
||||
sortOrder: 2,
|
||||
description: '活泼的弦乐节奏',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 二维 - 平面和声
|
||||
{
|
||||
name: '星座幻想',
|
||||
dimension: 2,
|
||||
url: 'https://example.com/bgm/dim2_constellation.mp3',
|
||||
duration: 150,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
sortOrder: 1,
|
||||
description: '梦幻的星空音乐,配合二维星座绘制',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '几何韵律',
|
||||
dimension: 2,
|
||||
url: 'https://example.com/bgm/dim2_geometry.mp3',
|
||||
duration: 110,
|
||||
loop: true,
|
||||
volume: 0.48,
|
||||
sortOrder: 2,
|
||||
description: '轻快的几何节奏',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 三维 - 立体环绕
|
||||
{
|
||||
name: '立方体回响',
|
||||
dimension: 3,
|
||||
url: 'https://example.com/bgm/dim3_cube_echo.mp3',
|
||||
duration: 180,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
sortOrder: 1,
|
||||
description: '空间感强烈的立体音效',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '多维共振',
|
||||
dimension: 3,
|
||||
url: 'https://example.com/bgm/dim3_resonance.mp3',
|
||||
duration: 140,
|
||||
loop: true,
|
||||
volume: 0.52,
|
||||
sortOrder: 2,
|
||||
description: '富有层次感的三维音效',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 四维 - 时间流动
|
||||
{
|
||||
name: '时空涟漪',
|
||||
dimension: 4,
|
||||
url: 'https://example.com/bgm/dim4_spacetime.mp3',
|
||||
duration: 200,
|
||||
loop: true,
|
||||
volume: 0.45,
|
||||
sortOrder: 1,
|
||||
description: '神秘的四维时空音乐',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '超立方漫游',
|
||||
dimension: 4,
|
||||
url: 'https://example.com/bgm/dim4_tesseract.mp3',
|
||||
duration: 165,
|
||||
loop: true,
|
||||
volume: 0.47,
|
||||
sortOrder: 2,
|
||||
description: '穿越维度的奇妙旅程',
|
||||
isActive: true
|
||||
},
|
||||
|
||||
// 五维 - 高维混沌
|
||||
{
|
||||
name: '思维风暴',
|
||||
dimension: 5,
|
||||
url: 'https://example.com/bgm/dim5_thought_storm.mp3',
|
||||
duration: 220,
|
||||
loop: true,
|
||||
volume: 0.5,
|
||||
sortOrder: 1,
|
||||
description: '复杂而迷人的高维音效',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
name: '量子纠缠',
|
||||
dimension: 5,
|
||||
url: 'https://example.com/bgm/dim5_quantum.mp3',
|
||||
duration: 190,
|
||||
loop: true,
|
||||
volume: 0.48,
|
||||
sortOrder: 2,
|
||||
description: '微观世界的奇妙声音',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
|
||||
async function initBGM() {
|
||||
try {
|
||||
// 连接数据库
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/yuzhiran')
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 检查是否已有BGM数据
|
||||
const existingCount = await BGM.countDocuments()
|
||||
if (existingCount > 0) {
|
||||
console.log(`⚠️ 数据库中已有 ${existingCount} 条BGM记录,跳过初始化`)
|
||||
await mongoose.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
// 插入示例数据
|
||||
const result = await BGM.insertMany(sampleBGMs)
|
||||
console.log(`✅ 成功初始化 ${result.length} 条BGM记录`)
|
||||
|
||||
// 按维度统计
|
||||
for (let dim = 1; dim <= 5; dim++) {
|
||||
const count = await BGM.countDocuments({ dimension: dim })
|
||||
console.log(` 维度${dim}: ${count} 首`)
|
||||
}
|
||||
|
||||
console.log('\n🎵 BGM初始化完成!')
|
||||
console.log('💡 提示:请在后台管理系统中上传真实的音频文件并更新URL')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ BGM初始化失败:', error)
|
||||
} finally {
|
||||
await mongoose.disconnect()
|
||||
console.log('数据库连接已关闭')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initBGM()
|
||||
Executable
+218
@@ -0,0 +1,218 @@
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') })
|
||||
const mongoose = require('mongoose')
|
||||
const { Admin, ShopItem, Knowledge, Gallery } = require('../src/models')
|
||||
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
*/
|
||||
async function initDatabase() {
|
||||
try {
|
||||
// 连接数据库
|
||||
const mongoURI = process.env.MONGODB_URI
|
||||
await mongoose.connect(mongoURI)
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 1. 创建超级管理员
|
||||
console.log('\n📋 创建超级管理员...')
|
||||
const existingAdmin = await Admin.findOne({ username: 'admin' })
|
||||
|
||||
if (!existingAdmin) {
|
||||
const admin = new Admin({
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'admin123456',
|
||||
email: process.env.ADMIN_EMAIL || 'admin@wdkj.com',
|
||||
realName: '超级管理员',
|
||||
role: 'super_admin',
|
||||
permissions: ['*'],
|
||||
status: 'active'
|
||||
})
|
||||
await admin.save()
|
||||
console.log('✅ 超级管理员创建成功')
|
||||
console.log(` 用户名: ${admin.username}`)
|
||||
console.log(` 密码: ${process.env.ADMIN_PASSWORD || 'admin123456'}`)
|
||||
} else {
|
||||
console.log('⚠️ 管理员账户已存在')
|
||||
}
|
||||
|
||||
// 2. 初始化商品
|
||||
console.log('\n📋 初始化商品...')
|
||||
const products = [
|
||||
{
|
||||
itemId: 'skin_quantum',
|
||||
name: '量子线皮肤',
|
||||
description: '一维世界专属皮肤,让你的线条闪耀量子光芒',
|
||||
price: 6,
|
||||
type: 'skin',
|
||||
status: 'active',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
itemId: 'skin_hologram',
|
||||
name: '全息面皮肤',
|
||||
description: '二维世界专属皮肤,让你的图形绽放全息色彩',
|
||||
price: 18,
|
||||
type: 'skin',
|
||||
status: 'active',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
itemId: 'skin_nebula',
|
||||
name: '星云体皮肤',
|
||||
description: '三维世界专属皮肤,让你的立方体流光溢彩',
|
||||
price: 30,
|
||||
type: 'skin',
|
||||
status: 'active',
|
||||
sortOrder: 3
|
||||
},
|
||||
{
|
||||
itemId: 'no_ads',
|
||||
name: '永久去广告',
|
||||
description: '一键去除所有广告,享受纯净的维度探索之旅。',
|
||||
price: 1200,
|
||||
type: 'ad_free',
|
||||
status: 'active',
|
||||
sortOrder: 4
|
||||
},
|
||||
{
|
||||
itemId: 'monthly_sub',
|
||||
name: '星云月卡',
|
||||
description: '尊享月度特权:全皮肤免费用、得分翻倍、专属身份标识。',
|
||||
price: 1800,
|
||||
type: 'vip',
|
||||
duration: 30,
|
||||
status: 'active',
|
||||
sortOrder: 5
|
||||
}
|
||||
]
|
||||
|
||||
for (const product of products) {
|
||||
const existing = await ShopItem.findOne({ itemId: product.itemId })
|
||||
if (!existing) {
|
||||
await ShopItem.create(product)
|
||||
console.log(` ✅ ${product.name} 创建成功`)
|
||||
} else {
|
||||
console.log(` ⚠️ ${product.name} 已存在`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 初始化知识库示例
|
||||
console.log('\n📋 初始化知识库示例...')
|
||||
const knowledgeList = [
|
||||
{
|
||||
title: '什么是维度?',
|
||||
content: '维度是描述空间独立方向的参数。零维是点,一维是线,二维是面,三维是体。',
|
||||
dim: 1,
|
||||
category: 'concept',
|
||||
tags: ['基础概念', '维度'],
|
||||
status: 'approved',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
title: '一维世界:线的宇宙',
|
||||
content: '一维世界只有长度这一个维度,就像一条无限延伸的直线。生活在一维世界的生物只能前进或后退。',
|
||||
dim: 1,
|
||||
category: 'concept',
|
||||
tags: ['一维', '线'],
|
||||
status: 'approved',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
title: '二维世界:平面王国',
|
||||
content: '二维世界有长度和宽度两个维度,就像一张无限大的纸。平面国中的生物可以在平面上自由移动。',
|
||||
dim: 2,
|
||||
category: 'concept',
|
||||
tags: ['二维', '平面'],
|
||||
status: 'approved',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
title: '三维世界:立体空间',
|
||||
content: '三维世界有长度、宽度和高度三个维度,这正是我们生活的世界。我们可以前后、左右、上下移动。',
|
||||
dim: 3,
|
||||
category: 'concept',
|
||||
tags: ['三维', '立体'],
|
||||
status: 'approved',
|
||||
sortOrder: 1
|
||||
}
|
||||
]
|
||||
|
||||
for (const knowledge of knowledgeList) {
|
||||
const existing = await Knowledge.findOne({ title: knowledge.title })
|
||||
if (!existing) {
|
||||
await Knowledge.create(knowledge)
|
||||
console.log(` ✅ ${knowledge.title} 创建成功`)
|
||||
} else {
|
||||
console.log(` ⚠️ ${knowledge.title} 已存在`)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 初始化画廊示例作品
|
||||
console.log('\n📋 初始化画廊示例作品...')
|
||||
const galleryWorks = [
|
||||
{
|
||||
title: '一维·星点轨迹',
|
||||
description: '在一维世界中,点动成线。这是我探索宇宙起点的轨迹记录。',
|
||||
imageData: '/static/images/gallery_demo_dim1.png',
|
||||
dim: 1,
|
||||
openid: 'system_admin',
|
||||
authorName: '系统管理员',
|
||||
authorAvatar: '/static/images/default_avatar.png',
|
||||
likeCount: 128,
|
||||
tags: ['一维', '轨迹', '起点'],
|
||||
status: 'approved',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
title: '二维·星座图腾',
|
||||
description: '连接星体,绘制出属于我的平面王国图腾。',
|
||||
imageData: '/static/images/gallery_demo_dim2.png',
|
||||
dim: 2,
|
||||
openid: 'system_admin',
|
||||
authorName: '星辰旅者',
|
||||
authorAvatar: '/static/images/default_avatar.png',
|
||||
likeCount: 96,
|
||||
tags: ['二维', '星座', '图腾'],
|
||||
status: 'approved',
|
||||
createdAt: new Date()
|
||||
},
|
||||
{
|
||||
title: '三维·流光立方',
|
||||
description: '旋转的立方体展现了立体几何的魅力,色彩在空间中流动。',
|
||||
imageData: '/static/images/gallery_demo_dim3.png',
|
||||
dim: 3,
|
||||
openid: 'system_admin',
|
||||
authorName: '维度探索者',
|
||||
authorAvatar: '/static/images/default_avatar.png',
|
||||
likeCount: 215,
|
||||
tags: ['三维', '立方体', '流光'],
|
||||
status: 'approved',
|
||||
createdAt: new Date()
|
||||
}
|
||||
]
|
||||
|
||||
for (const work of galleryWorks) {
|
||||
const existing = await Gallery.findOne({ title: work.title })
|
||||
if (!existing) {
|
||||
await Gallery.create(work)
|
||||
console.log(` ✅ ${work.title} 创建成功`)
|
||||
} else {
|
||||
console.log(` ⚠️ ${work.title} 已存在`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 数据库初始化完成!')
|
||||
console.log('\n📝 管理员登录信息:')
|
||||
console.log(` 用户名: ${process.env.ADMIN_USERNAME || 'admin'}`)
|
||||
console.log(` 密码: ${process.env.ADMIN_PASSWORD || 'admin123456'}`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error)
|
||||
} finally {
|
||||
await mongoose.connection.close()
|
||||
console.log('\n🔌 数据库连接已关闭')
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initDatabase()
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 初始化知识数据脚本
|
||||
* 用于填充测试知识内容
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config()
|
||||
|
||||
// 导入知识模型
|
||||
const Knowledge = require('../src/models/Knowledge')
|
||||
|
||||
// 测试知识数据
|
||||
const testKnowledgeData = [
|
||||
// 一维知识
|
||||
{
|
||||
title: '一维空间的基本概念',
|
||||
content: '一维空间是数学中最简单的空间形式,它只有长度这一个维度。在物理学中,一维空间可以用来描述直线运动,比如物体在直线上来回运动。一维空间的特点是所有点都在同一条直线上,没有宽度和高度。',
|
||||
dim: 1,
|
||||
category: 'concept',
|
||||
tags: ['一维', '空间', '数学'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 0,
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
title: '一维空间的数学表示',
|
||||
content: '在数学中,一维空间通常用实数轴来表示。实数轴上的每个点对应一个实数,点与点之间的距离就是它们对应实数的差的绝对值。一维空间中的向量只有一个分量,可以用一个实数来表示。',
|
||||
dim: 1,
|
||||
category: 'application',
|
||||
tags: ['一维', '数学', '实数轴'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 0,
|
||||
price: 0
|
||||
},
|
||||
|
||||
// 二维知识
|
||||
{
|
||||
title: '二维空间的几何特性',
|
||||
content: '二维空间具有两个维度:长度和宽度。在二维空间中,我们可以定义平面几何图形,如点、线、三角形、圆形等。二维空间中的每个点可以用两个坐标(x, y)来表示。',
|
||||
dim: 2,
|
||||
category: 'concept',
|
||||
tags: ['二维', '几何', '平面'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 10,
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
title: '二维坐标系的应用',
|
||||
content: '二维坐标系在计算机图形学、地理信息系统、工程设计等领域有广泛应用。笛卡尔坐标系是最常见的二维坐标系,它使用相互垂直的x轴和y轴来定位平面上的点。',
|
||||
dim: 2,
|
||||
category: 'application',
|
||||
tags: ['二维', '坐标系', '应用'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 20,
|
||||
price: 0
|
||||
},
|
||||
|
||||
// 三维知识
|
||||
{
|
||||
title: '理解三维空间',
|
||||
content: '三维空间是我们最熟悉的空间形式,它具有长度、宽度和高度三个维度。在三维空间中,物体具有体积,可以定义立体几何图形如立方体、球体、圆柱体等。',
|
||||
dim: 3,
|
||||
category: 'concept',
|
||||
tags: ['三维', '空间', '立体'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 30,
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
title: '三维建模技术',
|
||||
content: '三维建模技术在动画制作、游戏开发、建筑设计等领域有重要应用。常见的三维建模方法包括多边形建模、NURBS建模、体素建模等。',
|
||||
dim: 3,
|
||||
category: 'application',
|
||||
tags: ['三维', '建模', '技术'],
|
||||
status: 'approved',
|
||||
isPremium: false,
|
||||
requiredPoints: 40,
|
||||
price: 0
|
||||
},
|
||||
|
||||
// 四维知识(付费内容)
|
||||
{
|
||||
title: '四维时空的概念',
|
||||
content: '四维时空是爱因斯坦相对论中的核心概念,它将三维空间与时间维度结合。在四维时空中,事件的发生不仅取决于空间位置,还取决于时间点。四维时空的几何特性由闵可夫斯基度规描述。',
|
||||
dim: 4,
|
||||
category: 'concept',
|
||||
tags: ['四维', '时空', '相对论'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 9.9
|
||||
},
|
||||
{
|
||||
title: '四维空间的可视化',
|
||||
content: '由于人类无法直接感知四维空间,科学家们开发了多种可视化技术。常见的方法包括投影法、切片法、颜色编码法等,帮助我们理解高维空间的特性。',
|
||||
dim: 4,
|
||||
category: 'application',
|
||||
tags: ['四维', '可视化', '高维'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 12.9
|
||||
},
|
||||
|
||||
// 五维知识(高级付费内容)
|
||||
{
|
||||
title: '五维空间的物理意义',
|
||||
content: '五维空间理论在弦理论和卡鲁扎-克莱因理论中有重要应用。在五维空间中,除了三维空间和一维时间外,还可能存在额外的紧致维度。这些理论试图统一引力与其他基本力。',
|
||||
dim: 5,
|
||||
category: 'concept',
|
||||
tags: ['五维', '弦理论', '统一理论'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 19.9
|
||||
},
|
||||
{
|
||||
title: '高维空间的数学基础',
|
||||
content: '高维空间的数学研究涉及线性代数、微分几何、拓扑学等多个领域。n维空间中的点可以用n个坐标表示,距离和角度等概念可以通过推广低维空间的定义来建立。',
|
||||
dim: 5,
|
||||
category: 'application',
|
||||
tags: ['高维', '数学', '几何'],
|
||||
status: 'approved',
|
||||
isPremium: true,
|
||||
requiredPoints: 0,
|
||||
price: 24.9
|
||||
}
|
||||
]
|
||||
|
||||
async function initKnowledgeData() {
|
||||
try {
|
||||
// 连接数据库
|
||||
await mongoose.connect(process.env.MONGODB_URI, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
})
|
||||
|
||||
console.log('✅ 数据库连接成功')
|
||||
|
||||
// 清空现有知识数据(可选)
|
||||
await Knowledge.deleteMany({})
|
||||
console.log('✅ 已清空现有知识数据')
|
||||
|
||||
// 插入测试数据
|
||||
const inserted = await Knowledge.insertMany(testKnowledgeData)
|
||||
console.log(`✅ 成功插入 ${inserted.length} 条知识数据`)
|
||||
|
||||
// 显示插入的数据统计
|
||||
const dimCounts = {}
|
||||
inserted.forEach(item => {
|
||||
dimCounts[item.dim] = (dimCounts[item.dim] || 0) + 1
|
||||
})
|
||||
|
||||
console.log('📊 知识数据统计:')
|
||||
Object.keys(dimCounts).sort().forEach(dim => {
|
||||
console.log(` ${dim}维知识: ${dimCounts[dim]} 条`)
|
||||
})
|
||||
|
||||
const premiumCount = inserted.filter(item => item.isPremium).length
|
||||
const freeCount = inserted.length - premiumCount
|
||||
console.log(` 💰 付费内容: ${premiumCount} 条`)
|
||||
console.log(` 🆓 免费内容: ${freeCount} 条`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化知识数据失败:', error)
|
||||
} finally {
|
||||
await mongoose.connection.close()
|
||||
console.log('🔚 数据库连接已关闭')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
if (require.main === module) {
|
||||
initKnowledgeData()
|
||||
}
|
||||
|
||||
module.exports = { initKnowledgeData }
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 拼音探索模块初始化数据脚本
|
||||
* 创建拼音内容、成就等基础数据
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: './.env' });
|
||||
const mongoose = require('mongoose');
|
||||
const { PinyinContent, PinyinAchievement } = require('../src/models/pinyin');
|
||||
const connectDB = require('../src/config/database');
|
||||
|
||||
// 声母数据
|
||||
const initials = [
|
||||
{ symbol: 'b', name: '玻', order: 1 },
|
||||
{ symbol: 'p', name: '坡', order: 2 },
|
||||
{ symbol: 'm', name: '摸', order: 3 },
|
||||
{ symbol: 'f', name: '佛', order: 4 },
|
||||
{ symbol: 'd', name: '得', order: 5 },
|
||||
{ symbol: 't', name: '特', order: 6 },
|
||||
{ symbol: 'n', name: '讷', order: 7 },
|
||||
{ symbol: 'l', name: '勒', order: 8 },
|
||||
{ symbol: 'g', name: '哥', order: 9 },
|
||||
{ symbol: 'k', name: '科', order: 10 },
|
||||
{ symbol: 'h', name: '喝', order: 11 },
|
||||
{ symbol: 'j', name: '基', order: 12 },
|
||||
{ symbol: 'q', name: '期', order: 13 },
|
||||
{ symbol: 'x', name: '希', order: 14 },
|
||||
{ symbol: 'zh', name: '知', order: 15 },
|
||||
{ symbol: 'ch', name: '蚩', order: 16 },
|
||||
{ symbol: 'sh', name: '诗', order: 17 },
|
||||
{ symbol: 'r', name: '日', order: 18 },
|
||||
{ symbol: 'z', name: '资', order: 19 },
|
||||
{ symbol: 'c', name: '雌', order: 20 },
|
||||
{ symbol: 's', name: '思', order: 21 },
|
||||
{ symbol: 'y', name: '医', order: 22 },
|
||||
{ symbol: 'w', name: '巫', order: 23 }
|
||||
];
|
||||
|
||||
// 韵母数据
|
||||
const finals = [
|
||||
// 单韵母
|
||||
{ symbol: 'a', name: '啊', order: 1 },
|
||||
{ symbol: 'o', name: '喔', order: 2 },
|
||||
{ symbol: 'e', name: '鹅', order: 3 },
|
||||
{ symbol: 'i', name: '衣', order: 4 },
|
||||
{ symbol: 'u', name: '乌', order: 5 },
|
||||
{ symbol: 'ü', name: '迂', order: 6 },
|
||||
// 复韵母
|
||||
{ symbol: 'ai', name: '哀', order: 7 },
|
||||
{ symbol: 'ei', name: '诶', order: 8 },
|
||||
{ symbol: 'ui', name: '威', order: 9 },
|
||||
{ symbol: 'ao', name: '熬', order: 10 },
|
||||
{ symbol: 'ou', name: '欧', order: 11 },
|
||||
{ symbol: 'iu', name: '优', order: 12 },
|
||||
{ symbol: 'ie', name: '耶', order: 13 },
|
||||
{ symbol: 'üe', name: '约', order: 14 },
|
||||
{ symbol: 'er', name: '儿', order: 15 },
|
||||
// 前鼻韵母
|
||||
{ symbol: 'an', name: '安', order: 16 },
|
||||
{ symbol: 'en', name: '恩', order: 17 },
|
||||
{ symbol: 'in', name: '因', order: 18 },
|
||||
{ symbol: 'un', name: '温', order: 19 },
|
||||
{ symbol: 'ün', name: '晕', order: 20 },
|
||||
// 后鼻韵母
|
||||
{ symbol: 'ang', name: '昂', order: 21 },
|
||||
{ symbol: 'eng', name: '亨', order: 22 },
|
||||
{ symbol: 'ing', name: '英', order: 23 },
|
||||
{ symbol: 'ong', name: '雍', order: 24 }
|
||||
];
|
||||
|
||||
// 整体认读音节
|
||||
const overalls = [
|
||||
{ symbol: 'zhi', name: '织', order: 1 },
|
||||
{ symbol: 'chi', name: '吃', order: 2 },
|
||||
{ symbol: 'shi', name: '诗', order: 3 },
|
||||
{ symbol: 'ri', name: '日', order: 4 },
|
||||
{ symbol: 'zi', name: '资', order: 5 },
|
||||
{ symbol: 'ci', name: '雌', order: 6 },
|
||||
{ symbol: 'si', name: '思', order: 7 },
|
||||
{ symbol: 'yi', name: '衣', order: 8 },
|
||||
{ symbol: 'wu', name: '乌', order: 9 },
|
||||
{ symbol: 'yu', name: '迂', order: 10 },
|
||||
{ symbol: 'ye', name: '耶', order: 11 },
|
||||
{ symbol: 'yue', name: '约', order: 12 },
|
||||
{ symbol: 'yuan', name: '冤', order: 13 },
|
||||
{ symbol: 'yin', name: '因', order: 14 },
|
||||
{ symbol: 'yun', name: '晕', order: 15 },
|
||||
{ symbol: 'ying', name: '英', order: 16 }
|
||||
];
|
||||
|
||||
// 成就数据
|
||||
const achievements = [
|
||||
// 探索类成就
|
||||
{ code: 'first_explore', name: '初次探索', description: '完成首次拼音探索', type: 'explore', condition: { type: 'explore_count', value: 1 } },
|
||||
{ code: 'explorer_5', name: '初级探索者', description: '探索5个拼音', type: 'explore', condition: { type: 'explore_count', value: 5 } },
|
||||
{ code: 'explorer_10', name: '中级探索者', description: '探索10个拼音', type: 'explore', condition: { type: 'explore_count', value: 10 } },
|
||||
{ code: 'explorer_23', name: '声母专家', description: '探索所有声母', type: 'explore', condition: { type: 'explore_count', value: 23 } },
|
||||
{ code: 'explorer_47', name: '拼音达人', description: '探索47个拼音', type: 'explore', condition: { type: 'explore_count', value: 47 } },
|
||||
{ code: 'explorer_all', name: '拼音大师', description: '探索所有拼音', type: 'explore', condition: { type: 'explore_count', value: 63 } },
|
||||
|
||||
// 收集类成就
|
||||
{ code: 'first_stone', name: '第一颗能量石', description: '收集第一颗能量石', type: 'collection', condition: { type: 'collect_count', value: 1 } },
|
||||
{ code: 'collector_5', name: '能量收集者', description: '收集5颗能量石', type: 'collection', condition: { type: 'collect_count', value: 5 } },
|
||||
{ code: 'collector_10', name: '能量守护者', description: '收集10颗能量石', type: 'collection', condition: { type: 'collect_count', value: 10 } },
|
||||
{ code: 'collector_23', name: '声母守护者', description: '收集所有声母能量石', type: 'collection', condition: { type: 'collect_count', value: 23 } },
|
||||
{ code: 'collector_all', name: '能量大师', description: '收集所有能量石', type: 'collection', condition: { type: 'collect_count', value: 63 } },
|
||||
|
||||
// 连续探索成就
|
||||
{ code: 'streak_3', name: '坚持3天', description: '连续探索3天', type: 'streak', condition: { type: 'streak_days', value: 3 } },
|
||||
{ code: 'streak_7', name: '坚持一周', description: '连续探索7天', type: 'streak', condition: { type: 'streak_days', value: 7 } },
|
||||
{ code: 'streak_30', name: '坚持一个月', description: '连续探索30天', type: 'streak', condition: { type: 'streak_days', value: 30 } }
|
||||
];
|
||||
|
||||
async function initPinyinContents() {
|
||||
console.log('开始初始化拼音内容...');
|
||||
|
||||
// 初始化声母
|
||||
for (const item of initials) {
|
||||
await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: item.symbol },
|
||||
{
|
||||
symbol: item.symbol,
|
||||
type: 'initial',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
isFree: item.order <= 5, // 前5个免费
|
||||
status: 'active',
|
||||
pronunciation: `${item.name}的音`,
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${initials.length} 个声母`);
|
||||
|
||||
// 初始化韵母
|
||||
for (const item of finals) {
|
||||
await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: item.symbol },
|
||||
{
|
||||
symbol: item.symbol,
|
||||
type: 'final',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
isFree: false, // 韵母需要解锁
|
||||
status: 'active',
|
||||
pronunciation: `${item.name}的音`,
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${finals.length} 个韵母`);
|
||||
|
||||
// 初始化整体认读音节
|
||||
for (const item of overalls) {
|
||||
await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: item.symbol },
|
||||
{
|
||||
symbol: item.symbol,
|
||||
type: 'overall',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
isFree: false, // 整体认读需要解锁
|
||||
status: 'active',
|
||||
pronunciation: `${item.name}的音`,
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${overalls.length} 个整体认读音节`);
|
||||
}
|
||||
|
||||
async function initAchievements() {
|
||||
console.log('开始初始化成就...');
|
||||
|
||||
for (let i = 0; i < achievements.length; i++) {
|
||||
const item = achievements[i];
|
||||
await PinyinAchievement.findOneAndUpdate(
|
||||
{ code: item.code },
|
||||
{
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
condition: item.condition,
|
||||
order: i + 1,
|
||||
isActive: true,
|
||||
reward: {
|
||||
type: 'stone',
|
||||
value: 1
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
console.log(`✅ 已初始化 ${achievements.length} 个成就`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 连接数据库
|
||||
await connectDB();
|
||||
console.log('数据库连接成功');
|
||||
|
||||
// 初始化拼音内容
|
||||
await initPinyinContents();
|
||||
|
||||
// 初始化成就
|
||||
await initAchievements();
|
||||
|
||||
console.log('\n✨ 数据初始化完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* 数据库连接配置
|
||||
*/
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
const mongoURI = process.env.MONGODB_URI ||
|
||||
`mongodb://${process.env.MONGODB_USER}:${process.env.MONGODB_PASSWORD}@${process.env.MONGODB_HOST}:${process.env.MONGODB_PORT}/${process.env.MONGODB_DB}?authSource=${process.env.MONGODB_DB}`
|
||||
|
||||
const conn = await mongoose.connect(mongoURI, {
|
||||
// Mongoose 6+ 不再需要这些选项,但保留以兼容旧版本
|
||||
// useNewUrlParser: true,
|
||||
// useUnifiedTopology: true,
|
||||
})
|
||||
|
||||
console.log(`✅ MongoDB 连接成功: ${conn.connection.host}:${conn.connection.port}/${conn.connection.name}`)
|
||||
|
||||
// 连接事件监听
|
||||
mongoose.connection.on('error', (err) => {
|
||||
console.error('❌ MongoDB 连接错误:', err)
|
||||
})
|
||||
|
||||
mongoose.connection.on('disconnected', () => {
|
||||
console.warn('⚠️ MongoDB 连接断开')
|
||||
})
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGINT', async () => {
|
||||
await mongoose.connection.close()
|
||||
console.log('🔌 MongoDB 连接已关闭')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
return conn
|
||||
} catch (error) {
|
||||
console.error('❌ MongoDB 连接失败:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = connectDB
|
||||
@@ -0,0 +1,288 @@
|
||||
const { PinyinAchievement, PinyinProgress } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 获取成就列表
|
||||
* GET /api/pinyin/achievements
|
||||
*/
|
||||
exports.getAchievements = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
const query = { isActive: true };
|
||||
if (type) query.type = type;
|
||||
|
||||
const achievements = await PinyinAchievement.find(query)
|
||||
.sort({ order: 1, createdAt: 1 })
|
||||
.select('-__v');
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: achievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取成就列表失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取成就列表失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户已获得的成就
|
||||
* GET /api/pinyin/achievements/my
|
||||
*/
|
||||
exports.getMyAchievements = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const progress = await PinyinProgress.findOne({ userId });
|
||||
|
||||
if (!progress || !progress.achievements || progress.achievements.length === 0) {
|
||||
return res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
total: 0,
|
||||
list: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取成就详情
|
||||
const achievementCodes = progress.achievements.map(a => a.code);
|
||||
const achievements = await PinyinAchievement.find({
|
||||
code: { $in: achievementCodes }
|
||||
}).select('-__v');
|
||||
|
||||
// 合并获得时间
|
||||
const achievementsWithTime = achievements.map(ach => {
|
||||
const userAch = progress.achievements.find(a => a.code === ach.code);
|
||||
return {
|
||||
...ach.toObject(),
|
||||
obtainedAt: userAch ? userAch.obtainedAt : null
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
total: achievementsWithTime.length,
|
||||
list: achievementsWithTime
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取用户成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取用户成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查成就达成
|
||||
* POST /api/pinyin/achievements/check
|
||||
*/
|
||||
exports.checkAchievements = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
return res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
newAchievements: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有启用的成就
|
||||
const achievements = await PinyinAchievement.find({ isActive: true });
|
||||
|
||||
const newAchievements = [];
|
||||
|
||||
for (const achievement of achievements) {
|
||||
// 检查是否已获得
|
||||
if (progress.achievements.some(a => a.code === achievement.code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查条件
|
||||
const isAchieved = checkCondition(progress, achievement.condition);
|
||||
|
||||
if (isAchieved) {
|
||||
progress.achievements.push({
|
||||
code: achievement.code,
|
||||
obtainedAt: new Date()
|
||||
});
|
||||
|
||||
newAchievements.push({
|
||||
code: achievement.code,
|
||||
name: achievement.name,
|
||||
description: achievement.description,
|
||||
icon: achievement.icon,
|
||||
reward: achievement.reward
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newAchievements.length > 0) {
|
||||
await progress.save();
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
newAchievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('检查成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '检查成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建成就(管理员)
|
||||
* POST /api/pinyin/achievements
|
||||
*/
|
||||
exports.createAchievement = async (req, res) => {
|
||||
try {
|
||||
const achievementData = req.body;
|
||||
|
||||
// 检查code是否已存在
|
||||
const existing = await PinyinAchievement.findOne({ code: achievementData.code });
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '该成就代码已存在'
|
||||
});
|
||||
}
|
||||
|
||||
const achievement = new PinyinAchievement(achievementData);
|
||||
await achievement.save();
|
||||
|
||||
res.status(201).json({
|
||||
code: 0,
|
||||
message: '创建成功',
|
||||
data: achievement
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '创建成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新成就(管理员)
|
||||
* PUT /api/pinyin/achievements/:id
|
||||
*/
|
||||
exports.updateAchievement = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// 不允许修改code
|
||||
delete updateData.code;
|
||||
|
||||
const achievement = await PinyinAchievement.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!achievement) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '成就不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '更新成功',
|
||||
data: achievement
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '更新成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除成就(管理员)
|
||||
* DELETE /api/pinyin/achievements/:id
|
||||
*/
|
||||
exports.deleteAchievement = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const achievement = await PinyinAchievement.findByIdAndDelete(id);
|
||||
|
||||
if (!achievement) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '成就不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除成就失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '删除成就失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查成就条件
|
||||
* @param {Object} progress - 用户进度
|
||||
* @param {Object} condition - 条件
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function checkCondition(progress, condition) {
|
||||
switch (condition.type) {
|
||||
case 'explore_count':
|
||||
return progress.totalExplored >= condition.value;
|
||||
|
||||
case 'collect_count':
|
||||
return progress.totalStones >= condition.value;
|
||||
|
||||
case 'streak_days':
|
||||
return progress.streakDays >= condition.value;
|
||||
|
||||
case 'complete_symbol':
|
||||
return progress.isSymbolCompleted(condition.symbol);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
const { PinyinContent } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 获取拼音内容列表
|
||||
* GET /api/pinyin/contents
|
||||
*/
|
||||
exports.getContents = async (req, res) => {
|
||||
try {
|
||||
const { type, isFree, page = 1, limit = 20 } = req.query;
|
||||
|
||||
// 构建查询条件
|
||||
const query = { status: 'active' };
|
||||
if (type) query.type = type;
|
||||
if (isFree !== undefined) query.isFree = isFree === 'true';
|
||||
|
||||
// 分页
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// 查询数据
|
||||
const contents = await PinyinContent.find(query)
|
||||
.select('-__v')
|
||||
.sort({ order: 1, symbol: 1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit));
|
||||
|
||||
// 获取总数
|
||||
const total = await PinyinContent.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: contents,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
totalPages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取拼音内容列表失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取拼音内容列表失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个拼音详情
|
||||
* GET /api/pinyin/contents/:symbol
|
||||
*/
|
||||
exports.getContentBySymbol = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
const content = await PinyinContent.findOne({
|
||||
symbol: symbol.toLowerCase(),
|
||||
status: 'active'
|
||||
}).select('-__v');
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取拼音详情失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取拼音详情失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取拼音音频
|
||||
* GET /api/pinyin/contents/:symbol/audio
|
||||
*/
|
||||
exports.getContentAudio = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
const content = await PinyinContent.findOne({
|
||||
symbol: symbol.toLowerCase(),
|
||||
status: 'active'
|
||||
}).select('symbol audioUrl name');
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
symbol: content.symbol,
|
||||
audioUrl: content.audioUrl,
|
||||
name: content.name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取拼音音频失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取拼音音频失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建拼音内容(管理员)
|
||||
* POST /api/pinyin/contents
|
||||
*/
|
||||
exports.createContent = async (req, res) => {
|
||||
try {
|
||||
const contentData = req.body;
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await PinyinContent.findOne({ symbol: contentData.symbol });
|
||||
if (existing) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '该拼音内容已存在'
|
||||
});
|
||||
}
|
||||
|
||||
const content = new PinyinContent(contentData);
|
||||
await content.save();
|
||||
|
||||
res.status(201).json({
|
||||
code: 0,
|
||||
message: '创建成功',
|
||||
data: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建拼音内容失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '创建拼音内容失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新拼音内容(管理员)
|
||||
* PUT /api/pinyin/contents/:id
|
||||
*/
|
||||
exports.updateContent = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// 不允许修改symbol
|
||||
delete updateData.symbol;
|
||||
|
||||
const content = await PinyinContent.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '更新成功',
|
||||
data: content
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新拼音内容失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '更新拼音内容失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除拼音内容(管理员)
|
||||
* DELETE /api/pinyin/contents/:id
|
||||
*/
|
||||
exports.deleteContent = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const content = await PinyinContent.findByIdAndDelete(id);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '删除成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除拼音内容失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '删除拼音内容失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传拼音音频
|
||||
* POST /api/pinyin/contents/:symbol/audio
|
||||
*/
|
||||
exports.uploadAudio = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请上传音频文件'
|
||||
});
|
||||
}
|
||||
|
||||
// 构建文件URL(假设使用静态文件服务)
|
||||
const audioUrl = `/uploads/audio/${req.file.filename}`;
|
||||
|
||||
// 更新拼音内容的音频URL
|
||||
const content = await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: symbol.toLowerCase() },
|
||||
{ $set: { audioUrl } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '音频上传成功',
|
||||
data: { audioUrl }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传音频失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '上传音频失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传口型图
|
||||
* POST /api/pinyin/contents/:symbol/mouth-image
|
||||
*/
|
||||
exports.uploadMouthImage = async (req, res) => {
|
||||
try {
|
||||
const { symbol } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请上传图片文件'
|
||||
});
|
||||
}
|
||||
|
||||
// 构建文件URL
|
||||
const mouthImage = `/uploads/images/${req.file.filename}`;
|
||||
|
||||
// 更新拼音内容的口型图URL
|
||||
const content = await PinyinContent.findOneAndUpdate(
|
||||
{ symbol: symbol.toLowerCase() },
|
||||
{ $set: { mouthImage } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: '口型图上传成功',
|
||||
data: { mouthImage }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('上传口型图失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '上传口型图失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量更新音频URL(从外部TTS服务)
|
||||
* POST /api/pinyin/contents/batch-update-audio
|
||||
*/
|
||||
exports.batchUpdateAudio = async (req, res) => {
|
||||
try {
|
||||
const { audioBaseUrl } = req.body;
|
||||
|
||||
if (!audioBaseUrl) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请提供音频基础URL'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有拼音内容
|
||||
const contents = await PinyinContent.find({});
|
||||
|
||||
// 批量更新音频URL
|
||||
const updatePromises = contents.map(content => {
|
||||
const audioUrl = `${audioBaseUrl}/${content.symbol}.mp3`;
|
||||
return PinyinContent.findByIdAndUpdate(content._id, { $set: { audioUrl } });
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: `成功更新 ${contents.length} 个拼音的音频URL`,
|
||||
data: { updatedCount: contents.length }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('批量更新音频失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '批量更新音频失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,320 @@
|
||||
const { PinyinGameRecord, PinyinProgress, PinyinContent } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 游戏配置
|
||||
*/
|
||||
const GAME_CONFIG = {
|
||||
match: {
|
||||
name: '拼音配对',
|
||||
description: '找出相同的拼音卡片',
|
||||
difficulty: {
|
||||
easy: { pairs: 4, time: 60 },
|
||||
normal: { pairs: 6, time: 90 },
|
||||
hard: { pairs: 8, time: 120 }
|
||||
}
|
||||
},
|
||||
tone: {
|
||||
name: '声调过山车',
|
||||
description: '根据声调高低控制轨道',
|
||||
difficulty: {
|
||||
easy: { questions: 5, time: 60 },
|
||||
normal: { questions: 8, time: 90 },
|
||||
hard: { questions: 10, time: 120 }
|
||||
}
|
||||
},
|
||||
find: {
|
||||
name: '找拼音',
|
||||
description: '在场景中找到指定拼音',
|
||||
difficulty: {
|
||||
easy: { targets: 3, distractors: 6, time: 60 },
|
||||
normal: { targets: 5, distractors: 10, time: 90 },
|
||||
hard: { targets: 7, distractors: 14, time: 120 }
|
||||
}
|
||||
},
|
||||
puzzle: {
|
||||
name: '拼音拼图',
|
||||
description: '拖拽拼音组成完整音节',
|
||||
difficulty: {
|
||||
easy: { pieces: 2, time: 60 },
|
||||
normal: { pieces: 3, time: 90 },
|
||||
hard: { pieces: 4, time: 120 }
|
||||
}
|
||||
},
|
||||
mimic: {
|
||||
name: '语音模仿',
|
||||
description: '模仿发音,获得反馈',
|
||||
difficulty: {
|
||||
easy: { targets: 3, time: 60 },
|
||||
normal: { targets: 5, time: 90 },
|
||||
hard: { targets: 7, time: 120 }
|
||||
}
|
||||
},
|
||||
runner: {
|
||||
name: '拼音跑酷',
|
||||
description: '躲避障碍,收集正确拼音',
|
||||
difficulty: {
|
||||
easy: { distance: 100, obstacles: 5, time: 60 },
|
||||
normal: { distance: 200, obstacles: 10, time: 90 },
|
||||
hard: { distance: 300, obstacles: 15, time: 120 }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取游戏配置
|
||||
* GET /api/pinyin/games/config
|
||||
*/
|
||||
exports.getGameConfig = async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
games: GAME_CONFIG
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏配置失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏配置失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指定游戏配置
|
||||
* GET /api/pinyin/games/config/:type
|
||||
*/
|
||||
exports.getGameConfigByType = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
|
||||
if (!GAME_CONFIG[type]) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '游戏类型不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
type,
|
||||
config: GAME_CONFIG[type]
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏配置失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏配置失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录游戏结果
|
||||
* POST /api/pinyin/games/record
|
||||
*/
|
||||
exports.recordGame = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const {
|
||||
gameType,
|
||||
difficulty = 'normal',
|
||||
score,
|
||||
duration,
|
||||
correctCount,
|
||||
wrongCount,
|
||||
details = {}
|
||||
} = req.body;
|
||||
|
||||
// 验证参数
|
||||
if (!gameType || !GAME_CONFIG[gameType]) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '无效的游戏类型'
|
||||
});
|
||||
}
|
||||
|
||||
if (score === undefined || duration === undefined) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '缺少必要参数:score 或 duration'
|
||||
});
|
||||
}
|
||||
|
||||
// 计算准确率
|
||||
const total = (correctCount || 0) + (wrongCount || 0);
|
||||
const accuracy = total > 0 ? Math.round((correctCount / total) * 100) : 0;
|
||||
|
||||
// 创建游戏记录
|
||||
const record = new PinyinGameRecord({
|
||||
userId,
|
||||
gameType,
|
||||
difficulty,
|
||||
score,
|
||||
duration,
|
||||
correctCount: correctCount || 0,
|
||||
wrongCount: wrongCount || 0,
|
||||
accuracy,
|
||||
details,
|
||||
playedAt: new Date()
|
||||
});
|
||||
|
||||
await record.save();
|
||||
|
||||
// 更新用户进度中的游戏统计
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
progress = new PinyinProgress({ userId });
|
||||
}
|
||||
progress.updateDailyStats('game', duration);
|
||||
await progress.save();
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
record: {
|
||||
id: record._id,
|
||||
gameType,
|
||||
score,
|
||||
accuracy,
|
||||
playedAt: record.playedAt
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('记录游戏结果失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '记录游戏结果失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户游戏记录
|
||||
* GET /api/pinyin/games/records
|
||||
*/
|
||||
exports.getGameRecords = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const { gameType, page = 1, limit = 20 } = req.query;
|
||||
|
||||
const query = { userId };
|
||||
if (gameType) query.gameType = gameType;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const records = await PinyinGameRecord.find(query)
|
||||
.sort({ playedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.select('-__v');
|
||||
|
||||
const total = await PinyinGameRecord.countDocuments(query);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
list: records,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏记录失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏记录失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户游戏统计
|
||||
* GET /api/pinyin/games/stats
|
||||
*/
|
||||
exports.getGameStats = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const stats = await PinyinGameRecord.getUserStats(userId);
|
||||
|
||||
// 格式化统计数据
|
||||
const formattedStats = {};
|
||||
Object.keys(GAME_CONFIG).forEach(type => {
|
||||
const stat = stats.find(s => s._id === type);
|
||||
formattedStats[type] = {
|
||||
name: GAME_CONFIG[type].name,
|
||||
totalGames: stat ? stat.totalGames : 0,
|
||||
totalScore: stat ? stat.totalScore : 0,
|
||||
avgScore: stat ? Math.round(stat.avgScore) : 0,
|
||||
maxScore: stat ? stat.maxScore : 0,
|
||||
avgAccuracy: stat ? Math.round(stat.avgAccuracy) : 0
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
stats: formattedStats
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏统计失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏统计失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取游戏排行榜
|
||||
* GET /api/pinyin/games/leaderboard/:type
|
||||
*/
|
||||
exports.getLeaderboard = async (req, res) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
if (!GAME_CONFIG[type]) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '游戏类型不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const leaderboard = await PinyinGameRecord.getLeaderboard(type, parseInt(limit));
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
gameType: type,
|
||||
gameName: GAME_CONFIG[type].name,
|
||||
leaderboard
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取游戏排行榜失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取游戏排行榜失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
const { PinyinProgress, PinyinContent, PinyinAchievement } = require('../../models/pinyin');
|
||||
|
||||
/**
|
||||
* 获取用户探索进度
|
||||
* GET /api/pinyin/progress
|
||||
*/
|
||||
exports.getProgress = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
|
||||
// 如果没有进度记录,创建新记录
|
||||
if (!progress) {
|
||||
progress = new PinyinProgress({ userId });
|
||||
await progress.save();
|
||||
}
|
||||
|
||||
// 获取所有拼音内容数量
|
||||
const totalSymbols = await PinyinContent.countDocuments({ status: 'active' });
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
progress: {
|
||||
totalExplored: progress.totalExplored,
|
||||
totalStones: progress.totalStones,
|
||||
totalSymbols: totalSymbols,
|
||||
exploredSymbols: progress.exploredSymbols,
|
||||
currentSymbol: progress.currentSymbol,
|
||||
achievements: progress.achievements,
|
||||
streakDays: progress.streakDays
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取探索进度失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取探索进度失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 记录探索行为
|
||||
* POST /api/pinyin/progress/explore
|
||||
*/
|
||||
exports.recordExplore = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const { symbol, area, duration = 0 } = req.body;
|
||||
|
||||
if (!symbol || !area) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '缺少必要参数:symbol 或 area'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证拼音是否存在
|
||||
const content = await PinyinContent.findOne({
|
||||
symbol: symbol.toLowerCase(),
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '拼音内容不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 获取或创建进度记录
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
progress = new PinyinProgress({ userId });
|
||||
}
|
||||
|
||||
// 检查是否首次探索该拼音
|
||||
const isFirstExplore = !progress.hasExplored(symbol);
|
||||
|
||||
// 添加探索记录
|
||||
progress.addExplore(symbol, area);
|
||||
|
||||
// 更新每日统计
|
||||
progress.updateDailyStats('explore', duration);
|
||||
|
||||
// 检查是否完成所有区域
|
||||
const isCompleted = progress.isSymbolCompleted(symbol);
|
||||
|
||||
// 检查成就
|
||||
const newAchievements = await checkAchievements(progress);
|
||||
|
||||
await progress.save();
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
isFirstExplore,
|
||||
isCompleted,
|
||||
newAchievements,
|
||||
progress: {
|
||||
totalExplored: progress.totalExplored,
|
||||
totalStones: progress.totalStones,
|
||||
currentSymbol: progress.currentSymbol
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('记录探索行为失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '记录探索行为失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 收集能量石
|
||||
* POST /api/pinyin/progress/collect
|
||||
*/
|
||||
exports.collectStone = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
const { symbol } = req.body;
|
||||
|
||||
if (!symbol) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '缺少必要参数:symbol'
|
||||
});
|
||||
}
|
||||
|
||||
let progress = await PinyinProgress.findOne({ userId });
|
||||
if (!progress) {
|
||||
return res.status(404).json({
|
||||
code: 404,
|
||||
message: '探索进度不存在'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否已完成该拼音的所有区域
|
||||
if (!progress.isSymbolCompleted(symbol)) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '请先完成该拼音的所有探索区域'
|
||||
});
|
||||
}
|
||||
|
||||
// 收集能量石
|
||||
const isCollected = progress.collectStone(symbol);
|
||||
|
||||
if (!isCollected) {
|
||||
return res.status(400).json({
|
||||
code: 400,
|
||||
message: '该能量石已收集'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查成就
|
||||
const newAchievements = await checkAchievements(progress);
|
||||
|
||||
await progress.save();
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
isCollected: true,
|
||||
totalStones: progress.totalStones,
|
||||
newAchievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('收集能量石失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '收集能量石失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户探索统计
|
||||
* GET /api/pinyin/progress/stats
|
||||
*/
|
||||
exports.getStats = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user._id;
|
||||
|
||||
const progress = await PinyinProgress.findOne({ userId });
|
||||
|
||||
if (!progress) {
|
||||
return res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
totalExplored: 0,
|
||||
totalStones: 0,
|
||||
streakDays: 0,
|
||||
achievements: [],
|
||||
dailyStats: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取最近7天的统计
|
||||
const last7Days = progress.dailyStats
|
||||
.sort((a, b) => b.date - a.date)
|
||||
.slice(0, 7);
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
totalExplored: progress.totalExplored,
|
||||
totalStones: progress.totalStones,
|
||||
streakDays: progress.streakDays,
|
||||
achievements: progress.achievements,
|
||||
dailyStats: last7Days
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取探索统计失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取探索统计失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取探索排行榜
|
||||
* GET /api/pinyin/progress/leaderboard
|
||||
*/
|
||||
exports.getLeaderboard = async (req, res) => {
|
||||
try {
|
||||
const { type = 'stones', limit = 10 } = req.query;
|
||||
|
||||
let sortField = 'totalStones';
|
||||
if (type === 'explored') sortField = 'totalExplored';
|
||||
if (type === 'achievements') sortField = 'achievements';
|
||||
|
||||
const leaderboard = await PinyinProgress.find()
|
||||
.sort({ [sortField]: -1 })
|
||||
.limit(parseInt(limit))
|
||||
.populate('userId', 'nickname avatar')
|
||||
.select('totalStones totalExplored achievements streakDays');
|
||||
|
||||
const formattedLeaderboard = leaderboard.map((item, index) => ({
|
||||
rank: index + 1,
|
||||
userId: item.userId?._id,
|
||||
nickname: item.userId?.nickname || '匿名用户',
|
||||
avatar: item.userId?.avatar || '',
|
||||
totalStones: item.totalStones,
|
||||
totalExplored: item.totalExplored,
|
||||
achievements: item.achievements.length,
|
||||
streakDays: item.streakDays
|
||||
}));
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
type,
|
||||
leaderboard: formattedLeaderboard
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取排行榜失败:', error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: '获取排行榜失败',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查成就达成
|
||||
* @param {Object} progress - 用户进度对象
|
||||
* @returns {Array} - 新获得的成就列表
|
||||
*/
|
||||
async function checkAchievements(progress) {
|
||||
const newAchievements = [];
|
||||
|
||||
// 获取所有启用的成就
|
||||
const achievements = await PinyinAchievement.find({ isActive: true });
|
||||
|
||||
for (const achievement of achievements) {
|
||||
// 检查是否已获得
|
||||
if (progress.achievements.some(a => a.code === achievement.code)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查条件
|
||||
const isAchieved = checkAchievementCondition(progress, achievement.condition);
|
||||
|
||||
if (isAchieved) {
|
||||
progress.addAchievement(achievement.code);
|
||||
newAchievements.push({
|
||||
code: achievement.code,
|
||||
name: achievement.name,
|
||||
icon: achievement.icon
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newAchievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个成就条件
|
||||
* @param {Object} progress - 用户进度
|
||||
* @param {Object} condition - 成就条件
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function checkAchievementCondition(progress, condition) {
|
||||
switch (condition.type) {
|
||||
case 'explore_count':
|
||||
return progress.totalExplored >= condition.value;
|
||||
|
||||
case 'collect_count':
|
||||
return progress.totalStones >= condition.value;
|
||||
|
||||
case 'streak_days':
|
||||
return progress.streakDays >= condition.value;
|
||||
|
||||
case 'complete_symbol':
|
||||
return progress.isSymbolCompleted(condition.symbol);
|
||||
|
||||
case 'complete_type':
|
||||
// 需要查询该类型的所有拼音是否都已完成
|
||||
// 这里简化处理,实际应该查询数据库
|
||||
return false;
|
||||
|
||||
case 'explore_all':
|
||||
// 需要查询所有拼音数量
|
||||
return false;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
require('dotenv').config()
|
||||
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const helmet = require('helmet')
|
||||
const compression = require('compression')
|
||||
const morgan = require('morgan')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const connectDB = require('./config/database')
|
||||
const initRoutes = require('./routes')
|
||||
const logger = require('./utils/logger')
|
||||
|
||||
// 创建 Express 应用
|
||||
const app = express()
|
||||
|
||||
// 数据库连接
|
||||
connectDB()
|
||||
|
||||
// 中间件配置
|
||||
app.use(helmet()) // 安全头部
|
||||
app.use(cors()) // 跨域支持
|
||||
app.use(compression()) // Gzip 压缩
|
||||
app.use(express.json({ limit: '10mb' })) // JSON 解析
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' })) // URL 编码解析
|
||||
|
||||
// 日志中间件
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use(morgan('dev'))
|
||||
} else {
|
||||
app.use(morgan('combined'))
|
||||
}
|
||||
|
||||
// 速率限制
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 分钟
|
||||
max: 1000, // 每个 IP 最多 1000 次请求
|
||||
message: {
|
||||
success: false,
|
||||
error: '请求过于频繁,请稍后再试'
|
||||
}
|
||||
})
|
||||
app.use('/api/', limiter)
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
})
|
||||
})
|
||||
|
||||
// API 路由
|
||||
initRoutes(app)
|
||||
|
||||
// 404 处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: '接口不存在',
|
||||
path: req.path
|
||||
})
|
||||
})
|
||||
|
||||
// 全局错误处理
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('服务器错误:', err)
|
||||
|
||||
res.status(err.status || 500).json({
|
||||
success: false,
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? '服务器内部错误'
|
||||
: err.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
|
||||
})
|
||||
})
|
||||
|
||||
// 启动服务器
|
||||
const PORT = process.env.PORT || 3000
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(`🚀 服务器启动成功`)
|
||||
logger.info(`📍 端口: ${PORT}`)
|
||||
logger.info(`🌐 环境: ${process.env.NODE_ENV || 'development'}`)
|
||||
logger.info(`📊 健康检查: http://localhost:${PORT}/health`)
|
||||
})
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('📴 接收到 SIGTERM 信号,正在关闭服务器...')
|
||||
server.close(() => {
|
||||
logger.info('✅ 服务器已关闭')
|
||||
process.exit(0)
|
||||
})
|
||||
})
|
||||
|
||||
module.exports = app
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { Admin, User } = require('../models')
|
||||
|
||||
/**
|
||||
* JWT 认证中间件(管理员)
|
||||
*/
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
// 从 header 获取 token
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 验证 token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findById(decoded.adminId)
|
||||
if (!admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '管理员不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '管理员账户已禁用'
|
||||
})
|
||||
}
|
||||
|
||||
// 将管理员信息附加到请求对象
|
||||
req.admin = admin
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '无效的认证令牌'
|
||||
})
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限检查中间件
|
||||
* @param {string} permission - 需要的权限
|
||||
*/
|
||||
const requirePermission = (permission) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.admin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未认证'
|
||||
})
|
||||
}
|
||||
|
||||
if (!req.admin.hasPermission(permission)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序用户 JWT 认证中间件
|
||||
*/
|
||||
const userAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未提供认证令牌'
|
||||
})
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
|
||||
// 添加调试日志
|
||||
console.log('[Auth] Token received:', token.substring(0, 20) + '...')
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
console.log('[Auth] Token decoded:', decoded)
|
||||
|
||||
// 兼容两种token格式:包含userId或openid
|
||||
let user = null
|
||||
if (decoded.userId) {
|
||||
console.log('[Auth] Looking up user by userId:', decoded.userId)
|
||||
user = await User.findById(decoded.userId)
|
||||
} else if (decoded.openid) {
|
||||
console.log('[Auth] Looking up user by openid:', decoded.openid)
|
||||
user = await User.findOne({ openid: decoded.openid })
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.log('[Auth] User not found')
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在或令牌无效'
|
||||
})
|
||||
}
|
||||
|
||||
console.log('[Auth] User found:', user._id)
|
||||
console.log('[Auth] User openid:', user.openid)
|
||||
req.user = user
|
||||
// 优先使用 openid,如果没有则使用 userId 作为标识
|
||||
req.openid = user.openid || user._id.toString()
|
||||
next()
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '认证令牌无效或已过期'
|
||||
})
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选的用户认证中间件(不强制要求登录)
|
||||
*/
|
||||
const optionalAuthMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.header('Authorization')
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.replace('Bearer ', '')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
if (user) {
|
||||
req.user = user
|
||||
req.openid = user.openid
|
||||
}
|
||||
}
|
||||
next()
|
||||
} catch (error) {
|
||||
// 即使认证失败也继续,只是不附加用户信息
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authMiddleware,
|
||||
requirePermission,
|
||||
userAuthMiddleware,
|
||||
optionalAuthMiddleware
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* 用户AI问答次数记录模型
|
||||
*/
|
||||
const AIChatQuotaSchema = new mongoose.Schema({
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
// 剩余免费问答次数
|
||||
freeQuota: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
// 通过分享获得的次数
|
||||
sharedQuota: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 已使用的总次数
|
||||
usedQuota: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 今日已使用次数
|
||||
dailyUsed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 最后使用日期
|
||||
lastUsedDate: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
// 分享获得次数的记录
|
||||
shareRecords: [{
|
||||
sharedAt: { type: Date, default: Date.now },
|
||||
gainedQuota: { type: Number, default: 5 }
|
||||
}],
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
})
|
||||
|
||||
// 更新时自动更新 updatedAt
|
||||
AIChatQuotaSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
// 获取总可用次数
|
||||
AIChatQuotaSchema.methods.getTotalQuota = function() {
|
||||
return this.freeQuota + this.sharedQuota
|
||||
}
|
||||
|
||||
// 获取剩余可用次数
|
||||
AIChatQuotaSchema.methods.getRemainingQuota = function() {
|
||||
return this.getTotalQuota() - this.usedQuota
|
||||
}
|
||||
|
||||
// 检查是否有可用次数
|
||||
AIChatQuotaSchema.methods.hasQuota = function() {
|
||||
return this.getRemainingQuota() > 0
|
||||
}
|
||||
|
||||
// 使用一次问答机会
|
||||
AIChatQuotaSchema.methods.useQuota = async function() {
|
||||
if (!this.hasQuota()) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.usedQuota += 1
|
||||
this.dailyUsed += 1
|
||||
this.lastUsedDate = new Date()
|
||||
await this.save()
|
||||
return true
|
||||
}
|
||||
|
||||
// 通过分享增加次数
|
||||
AIChatQuotaSchema.methods.addQuotaByShare = async function(gainedQuota = 5) {
|
||||
this.sharedQuota += gainedQuota
|
||||
this.shareRecords.push({
|
||||
sharedAt: new Date(),
|
||||
gainedQuota
|
||||
})
|
||||
await this.save()
|
||||
return this.getRemainingQuota()
|
||||
}
|
||||
|
||||
// 重置每日使用次数(可定时任务调用)
|
||||
AIChatQuotaSchema.methods.resetDailyUsed = function() {
|
||||
const today = new Date().toDateString()
|
||||
const lastUsed = this.lastUsedDate ? new Date(this.lastUsedDate).toDateString() : null
|
||||
|
||||
if (lastUsed !== today) {
|
||||
this.dailyUsed = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('AIChatQuota', AIChatQuotaSchema)
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* AI模型配置模型
|
||||
*/
|
||||
const AIModelSchema = new mongoose.Schema({
|
||||
// 模型名称
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
// 模型ID
|
||||
modelId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
// API地址
|
||||
apiUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
// API Key
|
||||
apiKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// 模型描述
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 是否启用
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否为默认模型
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 模型参数配置
|
||||
config: {
|
||||
temperature: {
|
||||
type: Number,
|
||||
default: 0.7,
|
||||
min: 0,
|
||||
max: 2
|
||||
},
|
||||
maxTokens: {
|
||||
type: Number,
|
||||
default: 800,
|
||||
min: 1,
|
||||
max: 4096
|
||||
},
|
||||
topP: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
min: 0,
|
||||
max: 1
|
||||
}
|
||||
},
|
||||
// 优先级(数字越小优先级越高)
|
||||
priority: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
})
|
||||
|
||||
// 更新时自动更新 updatedAt
|
||||
AIModelSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
// 设置默认模型时,取消其他模型的默认状态
|
||||
AIModelSchema.pre('save', async function(next) {
|
||||
if (this.isDefault && this.isModified('isDefault')) {
|
||||
await this.constructor.updateMany(
|
||||
{ _id: { $ne: this._id } },
|
||||
{ isDefault: false }
|
||||
)
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// 获取当前使用的模型(优先返回默认模型,如果没有则返回第一个启用的模型)
|
||||
AIModelSchema.statics.getCurrentModel = async function() {
|
||||
// 先查找默认模型
|
||||
let model = await this.findOne({ isDefault: true, isActive: true })
|
||||
|
||||
// 如果没有默认模型,返回第一个启用的模型
|
||||
if (!model) {
|
||||
model = await this.findOne({ isActive: true }).sort({ priority: 1, createdAt: -1 })
|
||||
}
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
// 获取所有启用的模型列表
|
||||
AIModelSchema.statics.getActiveModels = async function() {
|
||||
return await this.find({ isActive: true }).sort({ priority: 1, createdAt: -1 })
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('AIModel', AIModelSchema)
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
/**
|
||||
* 管理员模型
|
||||
*/
|
||||
const AdminSchema = new Schema({
|
||||
// 登录信息
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
minlength: 3,
|
||||
maxlength: 30
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
trim: true,
|
||||
lowercase: true
|
||||
},
|
||||
|
||||
// 基本信息
|
||||
realName: String,
|
||||
phone: String,
|
||||
avatar: String,
|
||||
|
||||
// 角色
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['super_admin', 'content_manager', 'shop_manager', 'viewer'],
|
||||
default: 'viewer'
|
||||
},
|
||||
|
||||
// 权限
|
||||
permissions: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'suspended'],
|
||||
default: 'active'
|
||||
},
|
||||
|
||||
// 登录信息
|
||||
lastLogin: Date,
|
||||
lastLoginIp: String,
|
||||
loginCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引(username 已通过 unique: true 自动创建索引)
|
||||
AdminSchema.index({ role: 1 })
|
||||
|
||||
// 中间件:保存前加密密码
|
||||
AdminSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10)
|
||||
this.password = await bcrypt.hash(this.password, salt)
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 实例方法:验证密码
|
||||
AdminSchema.methods.validatePassword = async function(password) {
|
||||
return bcrypt.compare(password, this.password)
|
||||
}
|
||||
|
||||
// 实例方法:检查权限
|
||||
AdminSchema.methods.hasPermission = function(permission) {
|
||||
if (this.permissions.includes('*')) return true
|
||||
return this.permissions.includes(permission)
|
||||
}
|
||||
|
||||
// 静态方法:根据角色获取权限
|
||||
AdminSchema.statics.getPermissionsByRole = function(role) {
|
||||
const permissionMap = {
|
||||
super_admin: ['*'],
|
||||
content_manager: [
|
||||
'knowledge:read', 'knowledge:write', 'knowledge:delete',
|
||||
'gallery:read', 'gallery:approve', 'gallery:delete',
|
||||
'users:read'
|
||||
],
|
||||
shop_manager: [
|
||||
'shop:read', 'shop:write', 'shop:delete',
|
||||
'orders:read', 'orders:process', 'orders:refund',
|
||||
'users:read'
|
||||
],
|
||||
viewer: [
|
||||
'dashboard:read',
|
||||
'analytics:read',
|
||||
'users:read'
|
||||
]
|
||||
}
|
||||
|
||||
return permissionMap[role] || []
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Admin', AdminSchema)
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 管理员操作日志模型
|
||||
*/
|
||||
const AdminLogSchema = new Schema({
|
||||
// 管理员信息
|
||||
adminId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Admin',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
adminName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 操作信息
|
||||
action: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['login', 'create', 'read', 'update', 'delete', 'review', 'export']
|
||||
},
|
||||
resource: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
resourceId: String,
|
||||
|
||||
// 详细信息
|
||||
details: Schema.Types.Mixed,
|
||||
|
||||
// IP 地址
|
||||
ip: String,
|
||||
userAgent: String,
|
||||
|
||||
// 时间戳
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
}, {
|
||||
timestamps: false
|
||||
})
|
||||
|
||||
// 索引
|
||||
AdminLogSchema.index({ adminId: 1, timestamp: -1 })
|
||||
AdminLogSchema.index({ resource: 1, timestamp: -1 })
|
||||
AdminLogSchema.index({ action: 1 })
|
||||
|
||||
module.exports = mongoose.model('AdminLog', AdminLogSchema)
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
const BGMschema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
dimension: {
|
||||
type: Number,
|
||||
required: true,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
index: true
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
loop: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
volume: {
|
||||
type: Number,
|
||||
default: 0.5,
|
||||
min: 0,
|
||||
max: 1
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
index: true
|
||||
},
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
})
|
||||
|
||||
// 更新时自动修改 updatedAt
|
||||
BGMschema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
// 复合索引:维度 + 激活状态 + 排序
|
||||
BGMschema.index({ dimension: 1, isActive: 1, sortOrder: 1 })
|
||||
|
||||
module.exports = mongoose.model('BGM', BGMschema)
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 反馈模型
|
||||
* 存储用户的建议、问题和反馈
|
||||
*/
|
||||
const FeedbackSchema = new Schema({
|
||||
// 反馈者信息
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
openid: {
|
||||
type: String,
|
||||
index: true
|
||||
},
|
||||
userName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
userContact: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 反馈类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['suggestion', 'bug', 'complaint', 'praise', 'other'],
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 反馈标题
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 100
|
||||
},
|
||||
|
||||
// 反馈内容
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 2000
|
||||
},
|
||||
|
||||
// 图片附件
|
||||
images: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 相关页面/功能
|
||||
relatedPage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 设备信息
|
||||
deviceInfo: {
|
||||
device: { type: String, default: '' },
|
||||
os: { type: String, default: '' },
|
||||
osVersion: { type: String, default: '' },
|
||||
browser: { type: String, default: '' },
|
||||
browserVersion: { type: String, default: '' },
|
||||
screenResolution: { type: String, default: '' },
|
||||
appVersion: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 网络信息
|
||||
networkInfo: {
|
||||
type: { type: String, default: '' },
|
||||
ip: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 处理状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'processing', 'resolved', 'rejected', 'closed'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 优先级
|
||||
priority: {
|
||||
type: String,
|
||||
enum: ['low', 'normal', 'high', 'urgent'],
|
||||
default: 'normal'
|
||||
},
|
||||
|
||||
// 处理记录
|
||||
processLog: [{
|
||||
operator: { type: String, required: true },
|
||||
action: { type: String, required: true },
|
||||
comment: { type: String, default: '' },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
}],
|
||||
|
||||
// 处理结果
|
||||
result: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 处理人
|
||||
handler: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 处理时间
|
||||
handledAt: {
|
||||
type: Date
|
||||
},
|
||||
|
||||
// 用户评分(对处理结果的满意度)
|
||||
rating: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 5
|
||||
},
|
||||
|
||||
// 用户备注
|
||||
userRemark: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: { createdAt: false, updatedAt: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
FeedbackSchema.index({ status: 1, createdAt: -1 })
|
||||
FeedbackSchema.index({ type: 1, createdAt: -1 })
|
||||
FeedbackSchema.index({ priority: 1, createdAt: -1 })
|
||||
|
||||
// 静态方法:获取待处理的反馈数量
|
||||
FeedbackSchema.statics.getPendingCount = function() {
|
||||
return this.countDocuments({ status: 'pending' })
|
||||
}
|
||||
|
||||
// 静态方法:获取反馈统计
|
||||
FeedbackSchema.statics.getStats = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$status',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 静态方法:按类型统计
|
||||
FeedbackSchema.statics.getTypeStats = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$type',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Feedback', FeedbackSchema)
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 画廊作品模型
|
||||
*/
|
||||
const GallerySchema = new Schema({
|
||||
// 用户信息
|
||||
openid: {
|
||||
type: String,
|
||||
required: false,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
authorName: {
|
||||
type: String,
|
||||
default: '星辰旅者'
|
||||
},
|
||||
authorAvatar: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 作品信息
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
},
|
||||
|
||||
// 图片数据(Base64 或 URL)
|
||||
imageData: String,
|
||||
imageUrl: String,
|
||||
|
||||
// 作品属性
|
||||
tags: [{
|
||||
type: String,
|
||||
maxlength: 20
|
||||
}],
|
||||
dim: {
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
default: 1
|
||||
},
|
||||
|
||||
// 统计
|
||||
likeCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
viewCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 审核状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'approved', 'rejected'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
reviewComment: String,
|
||||
reviewedAt: Date,
|
||||
reviewerId: String,
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
GallerySchema.index({ openid: 1, createdAt: -1 })
|
||||
GallerySchema.index({ status: 1, createdAt: -1 })
|
||||
GallerySchema.index({ dim: 1, createdAt: -1 })
|
||||
GallerySchema.index({ likeCount: -1 })
|
||||
|
||||
// 静态方法:获取今日作品数
|
||||
GallerySchema.statics.getTodayCount = async function() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return this.countDocuments({
|
||||
createdAt: { $gte: today },
|
||||
status: 'approved'
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Gallery', GallerySchema)
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 知识库模型
|
||||
*/
|
||||
const KnowledgeSchema = new Schema({
|
||||
// 基本信息
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 分类
|
||||
dim: {
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
enum: ['concept', 'history', 'application', 'question'],
|
||||
required: true
|
||||
},
|
||||
tags: [{
|
||||
type: String,
|
||||
maxlength: 20
|
||||
}],
|
||||
|
||||
// 多层级内容
|
||||
sections: [{
|
||||
title: String,
|
||||
content: String,
|
||||
images: [String]
|
||||
}],
|
||||
|
||||
// 问答系统
|
||||
question: String,
|
||||
answer: String,
|
||||
options: [{
|
||||
text: String,
|
||||
isCorrect: Boolean
|
||||
}],
|
||||
|
||||
// 统计
|
||||
viewCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
collectionCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 付费与订阅
|
||||
isPremium: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requiredPoints: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'CNY'
|
||||
},
|
||||
|
||||
// 状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'pending', 'approved', 'rejected'],
|
||||
default: 'draft',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 审核信息
|
||||
reviewComment: String,
|
||||
reviewedAt: Date,
|
||||
reviewerId: String,
|
||||
|
||||
// 排序
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
KnowledgeSchema.index({ dim: 1, sortOrder: 1 })
|
||||
KnowledgeSchema.index({ status: 1, createdAt: -1 })
|
||||
KnowledgeSchema.index({ category: 1 })
|
||||
|
||||
// 中间件:保存前更新 updatedAt
|
||||
KnowledgeSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('Knowledge', KnowledgeSchema)
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 关卡配置模型
|
||||
*/
|
||||
const LevelSchema = new Schema({
|
||||
// 关卡基本信息
|
||||
dimension: {
|
||||
type: Number,
|
||||
required: true,
|
||||
enum: [1, 2, 3, 4, 5],
|
||||
index: true
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 关卡难度配置
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'normal', 'hard', 'expert'],
|
||||
default: 'normal'
|
||||
},
|
||||
|
||||
// 通关条件
|
||||
requirements: {
|
||||
score: { type: Number, default: 0 },
|
||||
timeLimit: { type: Number, default: 0 }, // 秒,0表示无限制
|
||||
collectibles: { type: Number, default: 0 } // 需要收集的物品数量
|
||||
},
|
||||
|
||||
// 奖励配置
|
||||
rewards: {
|
||||
exp: { type: Number, default: 100 },
|
||||
coins: { type: Number, default: 50 },
|
||||
unlockNext: { type: Boolean, default: true }
|
||||
},
|
||||
|
||||
// 关卡描述
|
||||
title: String,
|
||||
description: String,
|
||||
tips: [String], // 游戏提示
|
||||
|
||||
// 状态
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isLocked: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// 排序
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
})
|
||||
|
||||
// 复合索引:维度 + 关卡等级
|
||||
LevelSchema.index({ dimension: 1, level: 1 }, { unique: true })
|
||||
LevelSchema.index({ dimension: 1, isActive: 1, sortOrder: 1 })
|
||||
|
||||
module.exports = mongoose.model('Level', LevelSchema)
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 登录日志模型
|
||||
* 记录用户的登录/注册行为
|
||||
*/
|
||||
const LoginLogSchema = new Schema({
|
||||
// 用户标识
|
||||
userId: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 登录类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['register', 'login', 'auto_login'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// 登录方式
|
||||
method: {
|
||||
type: String,
|
||||
enum: ['wechat_miniapp', 'wechat_webview', 'wechat_oauth', 'username', 'other'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// IP地址
|
||||
ip: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 终端信息
|
||||
userAgent: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 设备信息
|
||||
device: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 操作系统
|
||||
os: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 浏览器
|
||||
browser: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 登录来源
|
||||
source: {
|
||||
type: String,
|
||||
enum: ['miniapp', 'webview', 'h5', 'admin', 'other'],
|
||||
default: 'other'
|
||||
},
|
||||
|
||||
// 登录结果
|
||||
success: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 失败原因
|
||||
failReason: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 地理位置(可选)
|
||||
location: {
|
||||
country: { type: String, default: '' },
|
||||
province: { type: String, default: '' },
|
||||
city: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 登录时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
}
|
||||
}, {
|
||||
timestamps: false
|
||||
})
|
||||
|
||||
// 索引
|
||||
LoginLogSchema.index({ userId: 1, createdAt: -1 })
|
||||
LoginLogSchema.index({ openid: 1, createdAt: -1 })
|
||||
LoginLogSchema.index({ type: 1, createdAt: -1 })
|
||||
LoginLogSchema.index({ method: 1, createdAt: -1 })
|
||||
|
||||
// 静态方法:获取用户的登录历史
|
||||
LoginLogSchema.statics.getUserLoginHistory = function(userId, limit = 10) {
|
||||
return this.find({ userId })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.lean()
|
||||
}
|
||||
|
||||
// 静态方法:获取最近的登录日志
|
||||
LoginLogSchema.statics.getRecentLogs = function(limit = 50) {
|
||||
return this.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.populate('userId', 'nickName avatarUrl')
|
||||
.lean()
|
||||
}
|
||||
|
||||
// 静态方法:统计登录数据
|
||||
LoginLogSchema.statics.getLoginStats = function(startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: startDate, $lte: endDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$type',
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('LoginLog', LoginLogSchema)
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 订单模型
|
||||
*/
|
||||
const OrderSchema = new Schema({
|
||||
// 订单基本信息
|
||||
orderNo: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// 用户信息
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 商品信息
|
||||
productId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
productName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 支付信息
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'paid', 'completed', 'failed', 'refunded'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
prepayId: String,
|
||||
transactionId: String,
|
||||
|
||||
// 微信支付参数(用于调试)
|
||||
paymentParams: Schema.Types.Mixed,
|
||||
rawResult: Schema.Types.Mixed,
|
||||
|
||||
// 审核信息(管理后台)
|
||||
reviewComment: String,
|
||||
reviewedAt: Date,
|
||||
reviewerId: String,
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: true
|
||||
},
|
||||
paidAt: Date,
|
||||
completedAt: Date,
|
||||
refundedAt: Date
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
OrderSchema.index({ openid: 1, createdAt: -1 })
|
||||
OrderSchema.index({ status: 1, createdAt: -1 })
|
||||
OrderSchema.index({ userId: 1, createdAt: -1 }) // 优化用户订单查询
|
||||
|
||||
// 静态方法:获取今日订单数
|
||||
OrderSchema.statics.getTodayCount = async function() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
return this.countDocuments({
|
||||
createdAt: { $gte: today },
|
||||
status: 'paid'
|
||||
})
|
||||
}
|
||||
|
||||
// 静态方法:获取订单趋势(7天)
|
||||
OrderSchema.statics.getWeekTrend = async function() {
|
||||
const weekAgo = new Date()
|
||||
weekAgo.setDate(weekAgo.getDate() - 7)
|
||||
weekAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: weekAgo },
|
||||
status: 'paid'
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$createdAt' }
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
revenue: { $sum: '$amount' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$sort: { '_id': 1 }
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = mongoose.model('Order', OrderSchema)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
const ShareRecordSchema = new mongoose.Schema({
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
index: true
|
||||
},
|
||||
shareType: {
|
||||
type: String,
|
||||
enum: ['app', 'dim1', 'dim2', 'dim3', 'dim4', 'dim5'],
|
||||
default: 'app'
|
||||
},
|
||||
score: { type: Number, default: 0 },
|
||||
sharedAt: { type: Date, default: Date.now }
|
||||
})
|
||||
|
||||
ShareRecordSchema.index({ sharedAt: -1 })
|
||||
|
||||
module.exports = mongoose.model('ShareRecord', ShareRecordSchema)
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
|
||||
/**
|
||||
* 商品模型
|
||||
*/
|
||||
const ShopItemSchema = new Schema({
|
||||
// 基本信息
|
||||
itemId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 价格(单位:分)
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
originalPrice: {
|
||||
type: Number
|
||||
},
|
||||
|
||||
// 商品类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['skin', 'noad', 'subscription'],
|
||||
required: true
|
||||
},
|
||||
|
||||
// 订阅天数(仅订阅商品)
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 30
|
||||
},
|
||||
|
||||
// 图片
|
||||
imageUrl: String,
|
||||
previewUrl: String,
|
||||
|
||||
// 统计
|
||||
purchaseCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 状态
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'discontinued'],
|
||||
default: 'active',
|
||||
index: true
|
||||
},
|
||||
|
||||
// 排序
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 促销
|
||||
isOnSale: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
saleEndTime: Date,
|
||||
|
||||
// 时间戳
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
ShopItemSchema.index({ status: 1, sortOrder: 1 })
|
||||
ShopItemSchema.index({ type: 1 })
|
||||
|
||||
// 虚拟字段:是否在促销中
|
||||
ShopItemSchema.virtual('isOnSaleActive').get(function() {
|
||||
return this.isOnSale && this.saleEndTime && new Date() < this.saleEndTime
|
||||
})
|
||||
|
||||
// 中间件:保存前更新 updatedAt
|
||||
ShopItemSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
next()
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('ShopItem', ShopItemSchema)
|
||||
@@ -0,0 +1,33 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { Schema } = mongoose;
|
||||
|
||||
const TrendSchema = new Schema({
|
||||
title: { type: String, required: true, trim: true },
|
||||
summary: { type: String },
|
||||
source: { type: String, default: '未知来源' },
|
||||
sourceUrl: { type: String },
|
||||
category: {
|
||||
type: String,
|
||||
enum: ['产品', '论文', '工具', '公司', '政策', '其他'],
|
||||
default: '其他'
|
||||
},
|
||||
tags: [String],
|
||||
hot: { type: Boolean, default: false },
|
||||
picked: { type: Boolean, default: false },
|
||||
newsDate: { type: Date, default: Date.now, index: true },
|
||||
viewCount: { type: Number, default: 0 },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'published'],
|
||||
default: 'published'
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
});
|
||||
|
||||
TrendSchema.index({ status: 1, newsDate: -1 });
|
||||
TrendSchema.index({ category: 1, newsDate: -1 });
|
||||
|
||||
module.exports = mongoose.model('Trend', TrendSchema);
|
||||
Executable
+305
@@ -0,0 +1,305 @@
|
||||
const mongoose = require('mongoose')
|
||||
const { Schema } = mongoose
|
||||
const bcrypt = require('bcryptjs')
|
||||
|
||||
/**
|
||||
* 用户探索数据结构
|
||||
*/
|
||||
const ExploreDataSchema = new Schema({
|
||||
dim1: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestScore: { type: Number, default: 0 },
|
||||
collectedEggs: { type: Number, default: 0 },
|
||||
totalLength: { type: Number, default: 0 }
|
||||
},
|
||||
dim2: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestArea: { type: Number, default: 0 },
|
||||
createdShapes: { type: Number, default: 0 }
|
||||
},
|
||||
dim3: {
|
||||
completed: { type: Boolean, default: false },
|
||||
exploredFaces: { type: Number, default: 0 },
|
||||
rotationTime: { type: Number, default: 0 },
|
||||
bestScore: { type: Number, default: 0 }
|
||||
},
|
||||
dim4: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestScore: { type: Number, default: 0 },
|
||||
exploredEvents: { type: Number, default: 0 }
|
||||
},
|
||||
dim5: {
|
||||
completed: { type: Boolean, default: false },
|
||||
bestScore: { type: Number, default: 0 },
|
||||
exploredThoughts: { type: Number, default: 0 }
|
||||
}
|
||||
}, { _id: false })
|
||||
|
||||
/**
|
||||
* 用户模型
|
||||
*/
|
||||
const UserSchema = new Schema({
|
||||
// 微信用户标识
|
||||
openid: {
|
||||
type: String,
|
||||
required: false,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
unionid: {
|
||||
type: String,
|
||||
index: true
|
||||
},
|
||||
|
||||
// H5环境用户名密码登录
|
||||
username: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
select: false
|
||||
},
|
||||
|
||||
// 基本信息
|
||||
nickName: {
|
||||
type: String,
|
||||
default: '星辰旅行者'
|
||||
},
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 探索数据
|
||||
exploreData: {
|
||||
type: ExploreDataSchema,
|
||||
default: () => ({})
|
||||
},
|
||||
|
||||
// 分数(冗余字段,便于排行榜查询)
|
||||
totalScore: { type: Number, default: 0 },
|
||||
dim1Score: { type: Number, default: 0 },
|
||||
dim2Score: { type: Number, default: 0 },
|
||||
dim3Score: { type: Number, default: 0 },
|
||||
dim4Score: { type: Number, default: 0 },
|
||||
dim5Score: { type: Number, default: 0 },
|
||||
|
||||
// 已解锁维度
|
||||
unlockedDims: [{
|
||||
type: Number,
|
||||
enum: [1, 2, 3, 4, 5]
|
||||
}],
|
||||
|
||||
// 商品相关
|
||||
ownedSkins: [{
|
||||
type: String
|
||||
}],
|
||||
purchasedItems: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 会员状态
|
||||
isAdFree: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSubscriber: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
subscribeExpiry: {
|
||||
type: Date
|
||||
},
|
||||
|
||||
// 收藏与成就
|
||||
collectedKnowledge: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Knowledge'
|
||||
}],
|
||||
achievements: [{
|
||||
type: String
|
||||
}],
|
||||
|
||||
// 设置
|
||||
settings: {
|
||||
voiceEnabled: { type: Boolean, default: true },
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'normal', 'hard'],
|
||||
default: 'normal'
|
||||
}
|
||||
},
|
||||
|
||||
// 探索等级系统
|
||||
level: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
levelPoints: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 时间戳
|
||||
totalPlayTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
lastLoginAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}, {
|
||||
timestamps: true,
|
||||
toJSON: { virtuals: true },
|
||||
toObject: { virtuals: true }
|
||||
})
|
||||
|
||||
// 索引
|
||||
UserSchema.index({ totalScore: -1 })
|
||||
UserSchema.index({ dim1Score: -1 })
|
||||
UserSchema.index({ dim2Score: -1 })
|
||||
UserSchema.index({ dim3Score: -1 })
|
||||
UserSchema.index({ dim4Score: -1 })
|
||||
UserSchema.index({ dim5Score: -1 })
|
||||
UserSchema.index({ createdAt: -1 })
|
||||
UserSchema.index({ lastLoginAt: -1 }) // 用于活跃用户统计
|
||||
|
||||
// 虚拟字段:是否是新用户(24小时内注册)
|
||||
UserSchema.virtual('isNew').get(function() {
|
||||
return Date.now() - this.createdAt < 24 * 60 * 60 * 1000
|
||||
})
|
||||
|
||||
// 中间件:保存前加密密码(仅针对username/password登录)
|
||||
UserSchema.pre('save', async function(next) {
|
||||
if (!this.isModified('password')) {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const salt = await bcrypt.genSalt(10)
|
||||
this.password = await bcrypt.hash(this.password, salt)
|
||||
next()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 实例方法:验证密码
|
||||
UserSchema.methods.validatePassword = async function(password) {
|
||||
return bcrypt.compare(password, this.password)
|
||||
}
|
||||
|
||||
// 实例方法:计算总分
|
||||
UserSchema.methods.calculateTotalScore = function() {
|
||||
this.totalScore =
|
||||
(this.exploreData.dim1?.bestScore || 0) +
|
||||
(this.exploreData.dim2?.bestArea || 0) +
|
||||
(this.exploreData.dim3?.bestScore || 0) +
|
||||
(this.exploreData.dim4?.bestScore || 0) +
|
||||
(this.exploreData.dim5?.bestScore || 0)
|
||||
|
||||
// 同步维度分数
|
||||
this.dim1Score = this.exploreData.dim1?.bestScore || 0
|
||||
this.dim2Score = this.exploreData.dim2?.bestArea || 0
|
||||
this.dim3Score = this.exploreData.dim3?.bestScore || 0
|
||||
this.dim4Score = this.exploreData.dim4?.bestScore || 0
|
||||
this.dim5Score = this.exploreData.dim5?.bestScore || 0
|
||||
|
||||
return this.totalScore
|
||||
}
|
||||
|
||||
// 实例方法:计算等级
|
||||
UserSchema.methods.calculateLevel = function() {
|
||||
// 等级计算公式:基于总分和探索时长
|
||||
// 基础分:每100分 = 1级
|
||||
const scorePoints = Math.floor(this.totalScore / 100)
|
||||
// 时长分:每1小时 = 1级
|
||||
const timePoints = Math.floor(this.totalPlayTime / 60)
|
||||
// 成就分:每个成就 = 2级
|
||||
const achievementPoints = (this.achievements?.length || 0) * 2
|
||||
// 收藏分:每5个收藏 = 1级
|
||||
const collectionPoints = Math.floor((this.collectedKnowledge?.length || 0) / 5)
|
||||
|
||||
this.levelPoints = scorePoints + timePoints + achievementPoints + collectionPoints
|
||||
|
||||
// 等级阈值表
|
||||
const levelThresholds = [
|
||||
0, 5, 15, 30, 50, 75, 105, 140, 180, 225,
|
||||
275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050
|
||||
]
|
||||
|
||||
// 计算等级
|
||||
let newLevel = 1
|
||||
for (let i = 0; i < levelThresholds.length; i++) {
|
||||
if (this.levelPoints >= levelThresholds[i]) {
|
||||
newLevel = i + 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.level = newLevel
|
||||
return this.level
|
||||
}
|
||||
|
||||
// 实例方法:获取等级信息
|
||||
UserSchema.methods.getLevelInfo = function() {
|
||||
const levelNames = [
|
||||
'星辰旅者', '维度学徒', '空间探索者', '几何学者', '维度行者',
|
||||
'时空旅人', '多维大师', '宇宙探索者', '维度掌控者', '空间主宰',
|
||||
'维度领主', '宇宙行者', '时空主宰', '维度之神', '宇宙之主',
|
||||
'维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神'
|
||||
]
|
||||
|
||||
const levelThresholds = [
|
||||
0, 5, 15, 30, 50, 75, 105, 140, 180, 225,
|
||||
275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050
|
||||
]
|
||||
|
||||
const currentLevel = this.level || 1
|
||||
const currentPoints = this.levelPoints || 0
|
||||
const nextLevelPoints = levelThresholds[currentLevel] || levelThresholds[levelThresholds.length - 1]
|
||||
const progress = Math.min(100, Math.floor((currentPoints / nextLevelPoints) * 100))
|
||||
|
||||
return {
|
||||
level: currentLevel,
|
||||
name: levelNames[Math.min(currentLevel - 1, levelNames.length - 1)],
|
||||
points: currentPoints,
|
||||
nextLevelPoints: nextLevelPoints,
|
||||
progress: progress,
|
||||
totalProgress: Math.min(100, Math.floor((currentPoints / 1050) * 100))
|
||||
}
|
||||
}
|
||||
|
||||
// 静态方法:获取排行榜
|
||||
UserSchema.statics.getLeaderboard = function(dim = null, limit = 50) {
|
||||
const sortField = dim ? `dim${dim}Score` : 'totalScore'
|
||||
return this.find()
|
||||
.select('openid nickName avatarUrl totalScore dim1Score dim2Score dim3Score dim4Score dim5Score unlockedDims')
|
||||
.sort({ [sortField]: -1 })
|
||||
.limit(limit)
|
||||
.lean()
|
||||
}
|
||||
|
||||
// 中间件:保存前更新 updatedAt、计算总分和等级
|
||||
UserSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now()
|
||||
this.calculateTotalScore()
|
||||
this.calculateLevel()
|
||||
next()
|
||||
})
|
||||
|
||||
module.exports = mongoose.model('User', UserSchema)
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
const User = require('./User')
|
||||
const Order = require('./Order')
|
||||
const Gallery = require('./Gallery')
|
||||
const Knowledge = require('./Knowledge')
|
||||
const ShopItem = require('./ShopItem')
|
||||
const Admin = require('./Admin')
|
||||
const AdminLog = require('./AdminLog')
|
||||
const ShareRecord = require('./ShareRecord')
|
||||
const BGM = require('./BGM')
|
||||
const Level = require('./Level')
|
||||
const AIChatQuota = require('./AIChatQuota')
|
||||
const AIModel = require('./AIModel')
|
||||
const LoginLog = require('./LoginLog')
|
||||
const Feedback = require('./Feedback')
|
||||
const Trend = require('./Trend')
|
||||
|
||||
// 拼音探索模块模型
|
||||
const pinyinModels = require('./pinyin')
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
Order,
|
||||
Gallery,
|
||||
Knowledge,
|
||||
ShopItem,
|
||||
Admin,
|
||||
AdminLog,
|
||||
ShareRecord,
|
||||
BGM,
|
||||
Level,
|
||||
AIChatQuota,
|
||||
AIModel,
|
||||
LoginLog,
|
||||
Feedback,
|
||||
Trend,
|
||||
// 拼音探索模型
|
||||
...pinyinModels
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 拼音探索成就模型
|
||||
* 定义可获得的成就及其条件
|
||||
*/
|
||||
const pinyinAchievementSchema = new mongoose.Schema({
|
||||
// 成就代码
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 成就名称
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 成就描述
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// 成就图标
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 成就类型
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['explore', 'game', 'collection', 'streak', 'special'],
|
||||
default: 'explore'
|
||||
},
|
||||
|
||||
// 达成条件
|
||||
condition: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: [
|
||||
'explore_count', // 探索数量
|
||||
'collect_count', // 收集数量
|
||||
'game_count', // 游戏次数
|
||||
'game_score', // 游戏分数
|
||||
'streak_days', // 连续天数
|
||||
'complete_symbol', // 完成指定拼音
|
||||
'complete_type', // 完成某类型所有拼音
|
||||
'explore_all' // 探索所有内容
|
||||
]
|
||||
},
|
||||
value: { type: Number, required: true }, // 条件值
|
||||
symbol: { type: String }, // 特定拼音(可选)
|
||||
symbolType: { type: String } // 特定类型(可选)
|
||||
},
|
||||
|
||||
// 奖励
|
||||
reward: {
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['stone', 'badge', 'theme', 'none'],
|
||||
default: 'none'
|
||||
},
|
||||
value: { type: Number, default: 0 },
|
||||
item: { type: String, default: '' }
|
||||
},
|
||||
|
||||
// 排序
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 是否启用
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinAchievementSchema.index({ type: 1, order: 1 });
|
||||
pinyinAchievementSchema.index({ isActive: 1 });
|
||||
pinyinAchievementSchema.index({ code: 1 });
|
||||
|
||||
// 更新中间件
|
||||
pinyinAchievementSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('PinyinAchievement', pinyinAchievementSchema);
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 拼音内容模型
|
||||
* 存储拼音字母的基础信息、音频、口型图、关联词语等
|
||||
*/
|
||||
const pinyinContentSchema = new mongoose.Schema({
|
||||
// 拼音符号,如 "b"
|
||||
symbol: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 拼音类型:initial(声母)/final(韵母)/overall(整体认读)
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['initial', 'final', 'overall']
|
||||
},
|
||||
|
||||
// 拼音名称,如 "玻"
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
|
||||
// 发音音频URL
|
||||
audioUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 口型示意图URL
|
||||
mouthImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 发音方法描述
|
||||
pronunciation: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 显示顺序
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 是否免费
|
||||
isFree: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 状态:active(启用)/inactive(禁用)
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive'],
|
||||
default: 'active'
|
||||
},
|
||||
|
||||
// 相关词语
|
||||
words: [{
|
||||
word: { type: String, required: true }, // 汉字
|
||||
pinyin: { type: String, required: true }, // 拼音
|
||||
image: { type: String, default: '' }, // 图片URL
|
||||
audioUrl: { type: String, default: '' }, // 音频URL
|
||||
meaning: { type: String, default: '' } // 释义
|
||||
}],
|
||||
|
||||
// 关联游戏配置
|
||||
games: [{
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['match', 'tone', 'find', 'puzzle', 'mimic', 'runner']
|
||||
},
|
||||
config: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
isEnabled: { type: Boolean, default: true }
|
||||
}],
|
||||
|
||||
// 探索区域配置
|
||||
exploreAreas: {
|
||||
audio: { type: Boolean, default: true }, // 声音发现
|
||||
mouth: { type: Boolean, default: true }, // 口型观察
|
||||
speak: { type: Boolean, default: true }, // 语音互动
|
||||
write: { type: Boolean, default: true }, // 书写体验
|
||||
game: { type: Boolean, default: true }, // 趣味互动
|
||||
words: { type: Boolean, default: true } // 词语发现
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinContentSchema.index({ type: 1, order: 1 });
|
||||
pinyinContentSchema.index({ status: 1 });
|
||||
pinyinContentSchema.index({ isFree: 1 });
|
||||
|
||||
// 更新中间件
|
||||
pinyinContentSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('PinyinContent', pinyinContentSchema);
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 拼音游戏记录模型
|
||||
* 记录用户的游戏行为和成绩
|
||||
*/
|
||||
const pinyinGameRecordSchema = new mongoose.Schema({
|
||||
// 用户ID
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
|
||||
// 游戏类型
|
||||
gameType: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['match', 'tone', 'find', 'puzzle', 'mimic', 'runner']
|
||||
},
|
||||
|
||||
// 游戏难度
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'normal', 'hard'],
|
||||
default: 'normal'
|
||||
},
|
||||
|
||||
// 得分
|
||||
score: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 游戏时长(秒)
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 正确数
|
||||
correctCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 错误数
|
||||
wrongCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 准确率
|
||||
accuracy: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 游戏详情
|
||||
details: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
default: {}
|
||||
},
|
||||
|
||||
// 是否完成
|
||||
isCompleted: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 游戏时间
|
||||
playedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinGameRecordSchema.index({ userId: 1, gameType: 1 });
|
||||
pinyinGameRecordSchema.index({ userId: 1, playedAt: -1 });
|
||||
pinyinGameRecordSchema.index({ gameType: 1, score: -1 });
|
||||
|
||||
// 静态方法:获取用户游戏统计
|
||||
pinyinGameRecordSchema.statics.getUserStats = async function(userId) {
|
||||
const stats = await this.aggregate([
|
||||
{ $match: { userId: mongoose.Types.ObjectId(userId) } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$gameType',
|
||||
totalGames: { $sum: 1 },
|
||||
totalScore: { $sum: '$score' },
|
||||
avgScore: { $avg: '$score' },
|
||||
maxScore: { $max: '$score' },
|
||||
totalDuration: { $sum: '$duration' },
|
||||
avgAccuracy: { $avg: '$accuracy' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
return stats;
|
||||
};
|
||||
|
||||
// 静态方法:获取排行榜
|
||||
pinyinGameRecordSchema.statics.getLeaderboard = async function(gameType, limit = 10) {
|
||||
const leaderboard = await this.aggregate([
|
||||
{ $match: { gameType: gameType } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$userId',
|
||||
maxScore: { $max: '$score' },
|
||||
totalGames: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { maxScore: -1 } },
|
||||
{ $limit: limit },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: '_id',
|
||||
foreignField: '_id',
|
||||
as: 'user'
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
userId: '$_id',
|
||||
maxScore: 1,
|
||||
totalGames: 1,
|
||||
nickname: { $arrayElemAt: ['$user.nickname', 0] },
|
||||
avatar: { $arrayElemAt: ['$user.avatar', 0] }
|
||||
}
|
||||
}
|
||||
]);
|
||||
return leaderboard;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('PinyinGameRecord', pinyinGameRecordSchema);
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* 用户拼音探索进度模型
|
||||
* 记录用户的探索进度、收集的能量石、成就等
|
||||
*/
|
||||
const pinyinProgressSchema = new mongoose.Schema({
|
||||
// 用户ID
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
|
||||
// 已探索的拼音
|
||||
exploredSymbols: [{
|
||||
symbol: { type: String, required: true }, // 拼音符号
|
||||
exploredAt: { type: Date, default: Date.now }, // 首次探索时间
|
||||
completedAreas: [{ type: String }], // 完成的探索区域
|
||||
isCollected: { type: Boolean, default: false }, // 是否收集能量石
|
||||
collectedAt: { type: Date }, // 收集时间
|
||||
lastExploredAt: { type: Date } // 最后探索时间
|
||||
}],
|
||||
|
||||
// 当前正在探索的拼音
|
||||
currentSymbol: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
|
||||
// 总探索数
|
||||
totalExplored: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 收集的能量石数量
|
||||
totalStones: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 获得的成就
|
||||
achievements: [{
|
||||
code: { type: String, required: true }, // 成就代码
|
||||
obtainedAt: { type: Date, default: Date.now } // 获得时间
|
||||
}],
|
||||
|
||||
// 每日统计
|
||||
dailyStats: [{
|
||||
date: { type: Date, required: true }, // 日期
|
||||
exploreCount: { type: Number, default: 0 }, // 探索次数
|
||||
gameCount: { type: Number, default: 0 }, // 游戏次数
|
||||
duration: { type: Number, default: 0 } // 时长(分钟)
|
||||
}],
|
||||
|
||||
// 连续探索天数
|
||||
streakDays: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// 最后探索日期
|
||||
lastExploreDate: {
|
||||
type: Date
|
||||
},
|
||||
|
||||
// 创建时间
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
|
||||
// 更新时间
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// 索引
|
||||
pinyinProgressSchema.index({ userId: 1 });
|
||||
pinyinProgressSchema.index({ totalExplored: -1 });
|
||||
|
||||
// 更新中间件
|
||||
pinyinProgressSchema.pre('save', function(next) {
|
||||
this.updatedAt = Date.now();
|
||||
next();
|
||||
});
|
||||
|
||||
// 方法:获取指定拼音的探索记录
|
||||
pinyinProgressSchema.methods.getSymbolProgress = function(symbol) {
|
||||
return this.exploredSymbols.find(s => s.symbol === symbol);
|
||||
};
|
||||
|
||||
// 方法:检查是否已探索指定拼音
|
||||
pinyinProgressSchema.methods.hasExplored = function(symbol) {
|
||||
return this.exploredSymbols.some(s => s.symbol === symbol);
|
||||
};
|
||||
|
||||
// 方法:检查是否完成指定拼音的所有区域
|
||||
pinyinProgressSchema.methods.isSymbolCompleted = function(symbol) {
|
||||
const record = this.getSymbolProgress(symbol);
|
||||
if (!record) return false;
|
||||
return record.completedAreas.length >= 6; // 6个探索区域
|
||||
};
|
||||
|
||||
// 方法:添加探索记录
|
||||
pinyinProgressSchema.methods.addExplore = function(symbol, area) {
|
||||
let record = this.getSymbolProgress(symbol);
|
||||
|
||||
if (!record) {
|
||||
record = {
|
||||
symbol: symbol,
|
||||
exploredAt: new Date(),
|
||||
completedAreas: [],
|
||||
isCollected: false
|
||||
};
|
||||
this.exploredSymbols.push(record);
|
||||
this.totalExplored += 1;
|
||||
}
|
||||
|
||||
if (!record.completedAreas.includes(area)) {
|
||||
record.completedAreas.push(area);
|
||||
}
|
||||
|
||||
record.lastExploredAt = new Date();
|
||||
this.currentSymbol = symbol;
|
||||
this.lastExploreDate = new Date();
|
||||
};
|
||||
|
||||
// 方法:收集能量石
|
||||
pinyinProgressSchema.methods.collectStone = function(symbol) {
|
||||
const record = this.getSymbolProgress(symbol);
|
||||
if (record && !record.isCollected) {
|
||||
record.isCollected = true;
|
||||
record.collectedAt = new Date();
|
||||
this.totalStones += 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 方法:添加成就
|
||||
pinyinProgressSchema.methods.addAchievement = function(code) {
|
||||
if (!this.achievements.some(a => a.code === code)) {
|
||||
this.achievements.push({
|
||||
code: code,
|
||||
obtainedAt: new Date()
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 方法:更新每日统计
|
||||
pinyinProgressSchema.methods.updateDailyStats = function(type, duration = 0) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let dailyStat = this.dailyStats.find(d => {
|
||||
const statDate = new Date(d.date);
|
||||
statDate.setHours(0, 0, 0, 0);
|
||||
return statDate.getTime() === today.getTime();
|
||||
});
|
||||
|
||||
if (!dailyStat) {
|
||||
dailyStat = {
|
||||
date: today,
|
||||
exploreCount: 0,
|
||||
gameCount: 0,
|
||||
duration: 0
|
||||
};
|
||||
this.dailyStats.push(dailyStat);
|
||||
}
|
||||
|
||||
if (type === 'explore') {
|
||||
dailyStat.exploreCount += 1;
|
||||
} else if (type === 'game') {
|
||||
dailyStat.gameCount += 1;
|
||||
}
|
||||
|
||||
if (duration > 0) {
|
||||
dailyStat.duration += duration;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('PinyinProgress', pinyinProgressSchema);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 拼音探索模块模型导出
|
||||
*/
|
||||
module.exports = {
|
||||
PinyinContent: require('./PinyinContent'),
|
||||
PinyinProgress: require('./PinyinProgress'),
|
||||
PinyinAchievement: require('./PinyinAchievement'),
|
||||
PinyinGameRecord: require('./PinyinGameRecord')
|
||||
};
|
||||
Executable
+1345
File diff suppressed because it is too large
Load Diff
Executable
+240
@@ -0,0 +1,240 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { AIChatQuota, AIModel } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取用户AI问答配额
|
||||
* GET /api/ai-chat/quota
|
||||
*/
|
||||
router.get('/quota', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = req.openid || req.user?.openid
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
let quota = await AIChatQuota.findOne({ openid })
|
||||
|
||||
if (!quota) {
|
||||
// 创建新的配额记录,默认5次免费
|
||||
quota = new AIChatQuota({ openid })
|
||||
await quota.save()
|
||||
}
|
||||
|
||||
// 检查是否需要重置每日使用次数
|
||||
quota.resetDailyUsed()
|
||||
await quota.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
freeQuota: quota.freeQuota,
|
||||
sharedQuota: quota.sharedQuota,
|
||||
usedQuota: quota.usedQuota,
|
||||
remainingQuota: quota.getRemainingQuota(),
|
||||
dailyUsed: quota.dailyUsed
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取AI问答配额失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* AI问答接口
|
||||
* POST /api/ai-chat/ask
|
||||
*/
|
||||
router.post('/ask', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = req.openid || req.user?.openid
|
||||
const { question, dimension = 'general' } = req.body
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
if (!question || question.trim().length === 0) {
|
||||
return ApiResponse.error(res, '请输入问题', 400)
|
||||
}
|
||||
|
||||
// 检查用户配额
|
||||
let quota = await AIChatQuota.findOne({ openid })
|
||||
|
||||
if (!quota) {
|
||||
quota = new AIChatQuota({ openid })
|
||||
await quota.save()
|
||||
}
|
||||
|
||||
if (!quota.hasQuota()) {
|
||||
return ApiResponse.error(res, '问答次数已用完,分享好友可获得更多次数', 403)
|
||||
}
|
||||
|
||||
// 获取当前使用的AI模型配置
|
||||
const aiModel = await AIModel.getCurrentModel()
|
||||
|
||||
let apiUrl, apiKey, modelId, modelConfig
|
||||
|
||||
if (aiModel) {
|
||||
// 使用数据库配置的模型
|
||||
apiUrl = aiModel.apiUrl
|
||||
apiKey = aiModel.apiKey
|
||||
modelId = aiModel.modelId
|
||||
modelConfig = aiModel.config
|
||||
} else {
|
||||
// 使用环境变量配置的模型
|
||||
apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions'
|
||||
apiKey = process.env.OPENAI_API_KEY
|
||||
modelId = process.env.OPENAI_MODEL || 'gpt-3.5-turbo'
|
||||
modelConfig = { temperature: 0.7, maxTokens: 800, topP: 1 }
|
||||
}
|
||||
|
||||
// 检查API配置
|
||||
if (!apiKey) {
|
||||
logger.error('AI API Key未配置')
|
||||
return ApiResponse.error(res, 'AI服务暂时不可用', 500)
|
||||
}
|
||||
|
||||
// 构建系统提示词
|
||||
const systemPrompt = getSystemPromptByDimension(dimension)
|
||||
|
||||
// 调用AI API
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
temperature: modelConfig.temperature || 0.7,
|
||||
max_tokens: modelConfig.maxTokens || 800,
|
||||
top_p: modelConfig.topP || 1
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('OpenAI API错误:', errorData)
|
||||
return ApiResponse.error(res, 'AI服务响应异常', 500)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const answer = data.choices[0].message.content
|
||||
|
||||
// 扣除一次使用次数
|
||||
await quota.useQuota()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
answer,
|
||||
remainingQuota: quota.getRemainingQuota()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AI问答失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 分享获得问答次数
|
||||
* POST /api/ai-chat/share-gain
|
||||
*/
|
||||
router.post('/share-gain', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const openid = req.openid || req.user?.openid
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
let quota = await AIChatQuota.findOne({ openid })
|
||||
|
||||
if (!quota) {
|
||||
quota = new AIChatQuota({ openid })
|
||||
}
|
||||
|
||||
// 增加5次分享获得的次数
|
||||
const remainingQuota = await quota.addQuotaByShare(5)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '分享成功,获得5次问答机会',
|
||||
gainedQuota: 5,
|
||||
remainingQuota
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('分享获得次数失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据维度获取系统提示词
|
||||
*/
|
||||
function getSystemPromptByDimension(dimension) {
|
||||
const prompts = {
|
||||
dim1: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 起源和历史的问题。
|
||||
请用通俗易懂的语言解释人工智能发展史上的重要事件、人物和里程碑,如:
|
||||
- 图灵测试(1950)与艾伦·图灵的贡献
|
||||
- 达特茅斯会议(1956)——AI 的诞生
|
||||
- 深蓝 vs 卡斯帕罗夫(1997)
|
||||
- AlphaGo 与深度学习革命(2016)
|
||||
- GPT 系列与大模型时代的开启
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim2: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 技术发展的问题。
|
||||
请用通俗易懂的语言解释人工智能核心技术演进,如:
|
||||
- 机器学习与深度学习的基本原理
|
||||
- Transformer 架构与自注意力机制
|
||||
- 大规模预训练模型(GPT、BERT等)
|
||||
- 多模态 AI、MoE 架构
|
||||
- Agent 智能体技术
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim3: `你是"宇之然AI维度"的AI助手,专门解答关于当前 AI 行业格局的问题。
|
||||
请用通俗易懂的语言解释当前 AI 领域的最新动态,如:
|
||||
- 各大模型公司(OpenAI、Google、DeepSeek、Anthropic、Meta)的竞争格局
|
||||
- 开源 vs 闭源模型的生态对比
|
||||
- 具身智能与世界模型的最新进展
|
||||
- AI 应用在各行业的落地情况
|
||||
- Agent 智能体的发展现状
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim4: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 学习和工具使用的问题。
|
||||
请用通俗易懂的语言解释 AI 学习路线和工具使用方法,如:
|
||||
- 提示词工程(Prompt Engineering)入门
|
||||
- 检索增强生成(RAG)的原理与实践
|
||||
- 模型微调(Fine-tuning)基础
|
||||
- 主流 AI 开发框架(LangChain、Dify 等)
|
||||
- 常用 AI 工具推荐与使用技巧
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
dim5: `你是"宇之然AI维度"的AI助手,专门解答关于 AI 趋势和前沿动态的问题。
|
||||
请用通俗易懂的语言介绍 AI 领域的最新趋势,如:
|
||||
- 最新 AI 产品发布动态
|
||||
- 前沿论文与研究方向
|
||||
- 行业趋势分析与预测
|
||||
- AI 政策法规与伦理讨论
|
||||
回答要简洁有趣,适合普通用户理解。`,
|
||||
|
||||
general: `你是"宇之然AI维度"的AI助手,致力于帮助用户系统化理解人工智能。
|
||||
你可以回答关于 AI 的任何问题,包括但不限于:
|
||||
- AI 历史与起源
|
||||
- 核心技术原理
|
||||
- 当前行业格局
|
||||
- 学习路线与工具使用
|
||||
- 最新趋势与前沿动态
|
||||
你的回答要简洁、通俗易懂,适合普通用户理解。如果用户问的是某个具体维度的知识,可以引导用户去对应的维度模块深入学习。`
|
||||
}
|
||||
|
||||
return prompts[dimension] || prompts.general
|
||||
}
|
||||
|
||||
module.exports = router
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { AIModel } = require('../models')
|
||||
const { authMiddleware, requirePermission } = require('../middleware/auth')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 所有路由都需要认证
|
||||
router.use(authMiddleware)
|
||||
|
||||
/**
|
||||
* 获取当前使用的模型(公开接口,用于AI问答)
|
||||
* GET /api/ai-model/current
|
||||
*/
|
||||
router.get('/current', async (req, res) => {
|
||||
try {
|
||||
const model = await AIModel.getCurrentModel()
|
||||
|
||||
if (!model) {
|
||||
// 如果没有配置模型,返回环境变量中的默认配置
|
||||
return ApiResponse.success(res, {
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 800,
|
||||
topP: 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 返回模型信息(不返回API Key)
|
||||
return ApiResponse.success(res, {
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
config: model.config
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取当前模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取模型列表(管理后台)
|
||||
* GET /api/ai-model/list
|
||||
*/
|
||||
router.get('/list', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 10, keyword = '' } = req.query
|
||||
|
||||
const query = keyword
|
||||
? { $or: [
|
||||
{ name: { $regex: keyword, $options: 'i' } },
|
||||
{ modelId: { $regex: keyword, $options: 'i' } }
|
||||
]}
|
||||
: {}
|
||||
|
||||
const [list, total] = await Promise.all([
|
||||
AIModel.find(query)
|
||||
.sort({ isDefault: -1, priority: 1, createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(parseInt(pageSize)),
|
||||
AIModel.countDocuments(query)
|
||||
])
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取模型列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有启用的模型(下拉选择用)
|
||||
* GET /api/ai-model/active
|
||||
*/
|
||||
router.get('/active', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const models = await AIModel.getActiveModels()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: models.map(m => ({
|
||||
_id: m._id,
|
||||
name: m.name,
|
||||
modelId: m.modelId,
|
||||
isDefault: m.isDefault
|
||||
}))
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取启用模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取模型详情
|
||||
* GET /api/ai-model/:id
|
||||
*/
|
||||
router.get('/:id', requirePermission('dashboard:read'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, { model })
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取模型详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建模型
|
||||
* POST /api/ai-model
|
||||
*/
|
||||
router.post('/', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { name, modelId, apiUrl, apiKey, description, config, priority } = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!name || !modelId || !apiUrl || !apiKey) {
|
||||
return ApiResponse.error(res, '请填写所有必填字段', 400)
|
||||
}
|
||||
|
||||
// 检查modelId是否已存在
|
||||
const existingModel = await AIModel.findOne({ modelId })
|
||||
if (existingModel) {
|
||||
return ApiResponse.error(res, '该模型ID已存在', 400)
|
||||
}
|
||||
|
||||
const model = new AIModel({
|
||||
name,
|
||||
modelId,
|
||||
apiUrl,
|
||||
apiKey,
|
||||
description,
|
||||
config,
|
||||
priority
|
||||
})
|
||||
|
||||
await model.save()
|
||||
|
||||
logger.info('创建AI模型:', { name, modelId, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '创建成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: model.isDefault,
|
||||
isActive: model.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新模型
|
||||
* PUT /api/ai-model/:id
|
||||
*/
|
||||
router.put('/:id', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, modelId, apiUrl, apiKey, description, isActive, isDefault, config, priority } = req.body
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
// 如果修改了modelId,检查是否与其他模型冲突
|
||||
if (modelId && modelId !== model.modelId) {
|
||||
const existingModel = await AIModel.findOne({ modelId, _id: { $ne: id } })
|
||||
if (existingModel) {
|
||||
return ApiResponse.error(res, '该模型ID已存在', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if (name) model.name = name
|
||||
if (modelId) model.modelId = modelId
|
||||
if (apiUrl) model.apiUrl = apiUrl
|
||||
if (apiKey) model.apiKey = apiKey
|
||||
if (description !== undefined) model.description = description
|
||||
if (isActive !== undefined) model.isActive = isActive
|
||||
if (isDefault !== undefined) model.isDefault = isDefault
|
||||
if (config) model.config = { ...model.config, ...config }
|
||||
if (priority !== undefined) model.priority = priority
|
||||
|
||||
await model.save()
|
||||
|
||||
logger.info('更新AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '更新成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: model.isDefault,
|
||||
isActive: model.isActive
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('更新模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除模型
|
||||
* DELETE /api/ai-model/:id
|
||||
*/
|
||||
router.delete('/:id', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
// 检查是否是唯一的默认模型
|
||||
if (model.isDefault) {
|
||||
const defaultCount = await AIModel.countDocuments({ isDefault: true })
|
||||
if (defaultCount <= 1) {
|
||||
return ApiResponse.error(res, '不能删除唯一的默认模型,请先设置其他默认模型', 400)
|
||||
}
|
||||
}
|
||||
|
||||
await AIModel.findByIdAndDelete(id)
|
||||
|
||||
logger.info('删除AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, { message: '删除成功' })
|
||||
|
||||
} catch (error) {
|
||||
logger.error('删除模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置默认模型
|
||||
* POST /api/ai-model/:id/set-default
|
||||
*/
|
||||
router.post('/:id/set-default', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const model = await AIModel.findById(id)
|
||||
|
||||
if (!model) {
|
||||
return ApiResponse.notFound(res, '模型不存在')
|
||||
}
|
||||
|
||||
if (!model.isActive) {
|
||||
return ApiResponse.error(res, '不能将禁用模型设为默认', 400)
|
||||
}
|
||||
|
||||
model.isDefault = true
|
||||
await model.save()
|
||||
|
||||
logger.info('设置默认AI模型:', { id, name: model.name, admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '设置默认模型成功',
|
||||
model: {
|
||||
_id: model._id,
|
||||
name: model.name,
|
||||
modelId: model.modelId,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('设置默认模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始化默认模型(从环境变量)
|
||||
* POST /api/ai-model/init-default
|
||||
*/
|
||||
router.post('/init-default', requirePermission('settings:write'), async (req, res) => {
|
||||
try {
|
||||
// 检查是否已有模型
|
||||
const existingCount = await AIModel.countDocuments()
|
||||
|
||||
if (existingCount > 0) {
|
||||
return ApiResponse.error(res, '已有模型配置,如需重新初始化请先删除现有模型', 400)
|
||||
}
|
||||
|
||||
// 从环境变量创建默认模型
|
||||
const defaultModel = new AIModel({
|
||||
name: '默认模型',
|
||||
modelId: process.env.OPENAI_MODEL || 'gpt-3.5-turbo',
|
||||
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||||
apiKey: process.env.OPENAI_API_KEY || '',
|
||||
description: '从环境变量初始化的默认模型',
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
priority: 0
|
||||
})
|
||||
|
||||
await defaultModel.save()
|
||||
|
||||
logger.info('初始化默认AI模型:', { admin: req.admin.username })
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
message: '初始化默认模型成功',
|
||||
model: {
|
||||
_id: defaultModel._id,
|
||||
name: defaultModel.name,
|
||||
modelId: defaultModel.modelId,
|
||||
isDefault: true
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('初始化默认模型失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+492
@@ -0,0 +1,492 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { User, Admin, AdminLog, LoginLog } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const logger = require('../utils/logger')
|
||||
const axios = require('axios')
|
||||
|
||||
/**
|
||||
* 获取客户端IP
|
||||
*/
|
||||
const getClientIp = (req) => {
|
||||
return req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析User-Agent
|
||||
*/
|
||||
const parseUserAgent = (userAgent) => {
|
||||
if (!userAgent) return { device: '', os: '', browser: '' }
|
||||
|
||||
const device = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent) ? 'mobile' : 'desktop'
|
||||
|
||||
let os = ''
|
||||
if (/Windows/i.test(userAgent)) os = 'Windows'
|
||||
else if (/Mac OS X/i.test(userAgent)) os = 'MacOS'
|
||||
else if (/Android/i.test(userAgent)) os = 'Android'
|
||||
else if (/iOS|iPhone|iPad|iPod/i.test(userAgent)) os = 'iOS'
|
||||
else if (/Linux/i.test(userAgent)) os = 'Linux'
|
||||
else os = 'Unknown'
|
||||
|
||||
let browser = ''
|
||||
if (/MicroMessenger/i.test(userAgent)) browser = 'WeChat'
|
||||
else if (/Chrome/i.test(userAgent)) browser = 'Chrome'
|
||||
else if (/Safari/i.test(userAgent)) browser = 'Safari'
|
||||
else if (/Firefox/i.test(userAgent)) browser = 'Firefox'
|
||||
else if (/Edge/i.test(userAgent)) browser = 'Edge'
|
||||
else browser = 'Unknown'
|
||||
|
||||
return { device, os, browser }
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录日志
|
||||
*/
|
||||
const recordLoginLog = async (userId, openid, type, method, source, req, success = true, failReason = '') => {
|
||||
try {
|
||||
const ip = getClientIp(req)
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const { device, os, browser } = parseUserAgent(userAgent)
|
||||
|
||||
await LoginLog.create({
|
||||
userId,
|
||||
openid,
|
||||
type,
|
||||
method,
|
||||
source,
|
||||
ip,
|
||||
userAgent,
|
||||
device,
|
||||
os,
|
||||
browser,
|
||||
success,
|
||||
failReason
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('记录登录日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序登录
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { code, userInfo, source = 'miniapp' } = req.body
|
||||
|
||||
// 方式1: 通过 code 登录(推荐)
|
||||
let openid = null
|
||||
if (code) {
|
||||
const sessionData = await weChatService.code2Session(code)
|
||||
openid = sessionData.openid
|
||||
}
|
||||
|
||||
// 方式2: 开发环境直接使用 openid (已废弃,仅用于本地调试)
|
||||
if (!openid && process.env.NODE_ENV === 'development' && req.body.openid) {
|
||||
openid = req.body.openid
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
let user = await User.findOne({ openid })
|
||||
let isNewUser = false
|
||||
|
||||
if (!user) {
|
||||
// 新用户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
isNewUser = true
|
||||
logger.info(`新用户注册: ${openid}`)
|
||||
} else {
|
||||
// 老用户,更新登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
if (userInfo?.nickName) user.nickName = userInfo.nickName
|
||||
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
isNewUser ? 'register' : 'login',
|
||||
'wechat_miniapp',
|
||||
source,
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
// 生成 JWT token(可选,小程序通常不需要)
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
isNewUser,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, isNewUser ? '注册成功' : '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView 静默登录
|
||||
* POST /api/auth/webview-login
|
||||
* 用于微信小程序 webview 内嵌 H5 的自动登录
|
||||
*/
|
||||
router.post('/webview-login', async (req, res) => {
|
||||
try {
|
||||
const { code, userInfo } = req.body
|
||||
|
||||
if (!code) {
|
||||
return ApiResponse.error(res, '缺少登录凭证', 400)
|
||||
}
|
||||
|
||||
// 通过 code 获取 openid
|
||||
let openid = null
|
||||
try {
|
||||
const sessionData = await weChatService.code2Session(code)
|
||||
openid = sessionData.openid
|
||||
} catch (error) {
|
||||
logger.error('WebView登录获取session失败:', error)
|
||||
return ApiResponse.error(res, '登录凭证无效', 401)
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '无法获取用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
let user = await User.findOne({ openid })
|
||||
let isNewUser = false
|
||||
|
||||
if (!user) {
|
||||
// 新用户 - 使用微信提供的信息创建账户
|
||||
user = new User({
|
||||
openid,
|
||||
nickName: userInfo?.nickName || '星辰旅行者',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
isNewUser = true
|
||||
logger.info(`WebView新用户注册: ${openid}`)
|
||||
} else {
|
||||
// 老用户,更新登录时间和信息
|
||||
user.lastLoginAt = new Date()
|
||||
if (userInfo?.nickName) user.nickName = userInfo.nickName
|
||||
if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl
|
||||
logger.info(`WebView老用户登录: ${openid}`)
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
isNewUser ? 'register' : 'auto_login',
|
||||
'wechat_webview',
|
||||
'webview',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
isNewUser,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, isNewUser ? '注册成功' : '自动登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
* POST /api/auth/admin/login
|
||||
*/
|
||||
router.post('/admin/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
// 查找管理员
|
||||
const admin = await Admin.findOne({ username })
|
||||
if (!admin) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isMatch = await admin.validatePassword(password)
|
||||
if (!isMatch) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if (admin.status !== 'active') {
|
||||
return ApiResponse.error(res, '账户已禁用', 403)
|
||||
}
|
||||
|
||||
// 更新登录信息
|
||||
admin.lastLogin = new Date()
|
||||
admin.lastLoginIp = req.ip
|
||||
admin.loginCount += 1
|
||||
await admin.save()
|
||||
|
||||
// 记录日志
|
||||
await AdminLog.create({
|
||||
adminId: admin._id,
|
||||
adminName: admin.username,
|
||||
action: 'login',
|
||||
resource: 'system',
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
})
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ adminId: admin._id, role: admin.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 获取权限
|
||||
const permissions = Admin.getPermissionsByRole(admin.role)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
adminId: admin._id,
|
||||
username: admin.username,
|
||||
role: admin.role,
|
||||
permissions
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('管理员登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户名密码登录
|
||||
* POST /api/auth/user/login
|
||||
*/
|
||||
router.post('/user/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
// 查找用户(显式包含密码字段)
|
||||
const user = await User.findOne({ username }).select('+password')
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const isMatch = await user.validatePassword(password)
|
||||
if (!isMatch) {
|
||||
return ApiResponse.error(res, '用户名或密码错误', 401)
|
||||
}
|
||||
|
||||
// 更新登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id, openid: user.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
userId: user._id,
|
||||
openid: user.openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
},
|
||||
firstPlayDate: user.createdAt
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户登录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* H5环境用户注册
|
||||
* POST /api/auth/user/register
|
||||
*/
|
||||
router.post('/user/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password, nickName, avatarUrl } = req.body
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existingUser = await User.findOne({ username })
|
||||
if (existingUser) {
|
||||
return ApiResponse.error(res, '用户名已存在', 400)
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
const user = new User({
|
||||
username,
|
||||
password,
|
||||
nickName: nickName || '星辰旅行者',
|
||||
avatarUrl: avatarUrl || '',
|
||||
unlockedDims: [1]
|
||||
})
|
||||
await user.save()
|
||||
|
||||
logger.info(`新用户注册: ${username}`)
|
||||
|
||||
// 生成 JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
userId: user._id,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '注册成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('用户注册失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return ApiResponse.unauthorized(res)
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
const user = await User.findById(decoded.userId)
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.notFound(res, '用户不存在')
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, user)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户信息失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* WebView Token 交换接口
|
||||
* POST /api/auth/webview-token
|
||||
* 用于小程序 WebView 内嵌 H5 时,通过 openid 获取登录 token
|
||||
* 小程序在 URL 中传递 openid,H5 用这个接口换取 token
|
||||
*/
|
||||
router.post('/webview-token', async (req, res) => {
|
||||
try {
|
||||
const { openid } = req.body
|
||||
|
||||
if (!openid) {
|
||||
return ApiResponse.error(res, '缺少用户标识', 400)
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
const user = await User.findOne({ openid })
|
||||
|
||||
if (!user) {
|
||||
return ApiResponse.error(res, '用户不存在', 404)
|
||||
}
|
||||
|
||||
// 生成新的 JWT token
|
||||
const token = jwt.sign(
|
||||
{ openid, userId: user._id },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRE }
|
||||
)
|
||||
|
||||
// 更新最后登录时间
|
||||
user.lastLoginAt = new Date()
|
||||
await user.save()
|
||||
|
||||
// 记录登录日志
|
||||
await recordLoginLog(
|
||||
user._id,
|
||||
openid,
|
||||
'webview_auto_login',
|
||||
'wechat_webview',
|
||||
'webview_h5',
|
||||
req,
|
||||
true
|
||||
)
|
||||
|
||||
logger.info(`WebView Token交换成功: ${openid}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
token,
|
||||
openid,
|
||||
userInfo: {
|
||||
nickName: user.nickName,
|
||||
avatarUrl: user.avatarUrl
|
||||
}
|
||||
}, '登录成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WebView Token交换失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { BGM } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { authMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取指定维度的随机BGM(公开接口,移动端调用)
|
||||
* GET /api/bgm/random/:dimension
|
||||
*/
|
||||
router.get('/random/:dimension', async (req, res) => {
|
||||
try {
|
||||
const dimension = parseInt(req.params.dimension)
|
||||
|
||||
if (![1, 2, 3, 4, 5].includes(dimension)) {
|
||||
return ApiResponse.badRequest(res, '无效的维度参数')
|
||||
}
|
||||
|
||||
// 查询该维度下所有激活的BGM
|
||||
const bgms = await BGM.find({
|
||||
dimension,
|
||||
isActive: true
|
||||
}).sort({ sortOrder: 1 })
|
||||
|
||||
if (!bgms || bgms.length === 0) {
|
||||
return ApiResponse.success(res, null)
|
||||
}
|
||||
|
||||
// 随机选择一个
|
||||
const randomIndex = Math.floor(Math.random() * bgms.length)
|
||||
const selectedBGM = bgms[randomIndex]
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: selectedBGM._id,
|
||||
name: selectedBGM.name,
|
||||
url: selectedBGM.url,
|
||||
duration: selectedBGM.duration,
|
||||
loop: selectedBGM.loop,
|
||||
volume: selectedBGM.volume,
|
||||
dimension: selectedBGM.dimension
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取随机BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取所有BGM列表(管理员)
|
||||
* GET /api/admin/bgm/list
|
||||
*/
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { dimension, page = 1, limit = 20 } = req.query
|
||||
|
||||
const query = {}
|
||||
if (dimension && [1, 2, 3, 4, 5].includes(parseInt(dimension))) {
|
||||
query.dimension = parseInt(dimension)
|
||||
}
|
||||
|
||||
const pageNum = parseInt(page)
|
||||
const limitNum = parseInt(limit)
|
||||
const skip = (pageNum - 1) * limitNum
|
||||
|
||||
const [bgms, total] = await Promise.all([
|
||||
BGM.find(query)
|
||||
.sort({ dimension: 1, sortOrder: 1, createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limitNum),
|
||||
BGM.countDocuments(query)
|
||||
])
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: bgms,
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取BGM列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 创建BGM(管理员)
|
||||
* POST /api/admin/bgm/create
|
||||
*/
|
||||
router.post('/create', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { name, dimension, url, duration, loop, volume, sortOrder, description } = req.body
|
||||
|
||||
if (!name || !dimension || !url) {
|
||||
return ApiResponse.badRequest(res, '名称、维度和URL为必填项')
|
||||
}
|
||||
|
||||
if (![1, 2, 3, 4, 5].includes(parseInt(dimension))) {
|
||||
return ApiResponse.badRequest(res, '无效的维度参数')
|
||||
}
|
||||
|
||||
const bgm = new BGM({
|
||||
name,
|
||||
dimension: parseInt(dimension),
|
||||
url,
|
||||
duration: duration || 0,
|
||||
loop: loop !== undefined ? loop : true,
|
||||
volume: volume !== undefined ? volume : 0.5,
|
||||
sortOrder: sortOrder || 0,
|
||||
description: description || ''
|
||||
})
|
||||
|
||||
await bgm.save()
|
||||
|
||||
logger.info(`管理员创建了BGM: ${name} (维度${dimension})`)
|
||||
return ApiResponse.success(res, { message: 'BGM创建成功', data: bgm })
|
||||
} catch (error) {
|
||||
logger.error('创建BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新BGM(管理员)
|
||||
* PUT /api/admin/bgm/update/:id
|
||||
*/
|
||||
router.put('/update/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const updates = req.body
|
||||
|
||||
const bgm = await BGM.findByIdAndUpdate(
|
||||
id,
|
||||
{ $set: updates },
|
||||
{ new: true, runValidators: true }
|
||||
)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
logger.info(`管理员更新了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, { message: 'BGM更新成功', data: bgm })
|
||||
} catch (error) {
|
||||
logger.error('更新BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除BGM(管理员)
|
||||
* DELETE /api/admin/bgm/delete/:id
|
||||
*/
|
||||
router.delete('/delete/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const bgm = await BGM.findByIdAndDelete(id)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
logger.info(`管理员删除了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, { message: 'BGM删除成功' })
|
||||
} catch (error) {
|
||||
logger.error('删除BGM失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 切换BGM激活状态(管理员)
|
||||
* PUT /api/admin/bgm/toggle/:id
|
||||
*/
|
||||
router.put('/toggle/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const bgm = await BGM.findById(id)
|
||||
|
||||
if (!bgm) {
|
||||
return ApiResponse.notFound(res, 'BGM不存在')
|
||||
}
|
||||
|
||||
bgm.isActive = !bgm.isActive
|
||||
await bgm.save()
|
||||
|
||||
logger.info(`管理员${bgm.isActive ? '激活' : '禁用'}了BGM: ${bgm.name}`)
|
||||
return ApiResponse.success(res, {
|
||||
message: `BGM已${bgm.isActive ? '激活' : '禁用'}`,
|
||||
data: bgm
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('切换BGM状态失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Feedback, User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取客户端IP
|
||||
*/
|
||||
const getClientIp = (req) => {
|
||||
return req.headers['x-forwarded-for'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交反馈
|
||||
* POST /api/feedback
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
images,
|
||||
relatedPage,
|
||||
deviceInfo,
|
||||
userContact
|
||||
} = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!type || !title || !content) {
|
||||
return ApiResponse.error(res, '请填写完整的反馈信息', 400)
|
||||
}
|
||||
|
||||
// 获取用户信息(如果已登录)
|
||||
let userId = null
|
||||
let openid = null
|
||||
let userName = ''
|
||||
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '')
|
||||
if (token) {
|
||||
const jwt = require('jsonwebtoken')
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET)
|
||||
userId = decoded.userId
|
||||
openid = decoded.openid
|
||||
|
||||
// 获取用户信息
|
||||
const user = await User.findById(userId)
|
||||
if (user) {
|
||||
userName = user.nickName || ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Token 验证失败,继续以匿名方式提交
|
||||
logger.warn('反馈提交时Token验证失败:', error.message)
|
||||
}
|
||||
|
||||
// 创建反馈记录
|
||||
const feedback = new Feedback({
|
||||
userId,
|
||||
openid,
|
||||
userName,
|
||||
userContact: userContact || '',
|
||||
type,
|
||||
title,
|
||||
content,
|
||||
images: images || [],
|
||||
relatedPage: relatedPage || '',
|
||||
deviceInfo: deviceInfo || {},
|
||||
networkInfo: {
|
||||
ip: getClientIp(req)
|
||||
},
|
||||
status: 'pending',
|
||||
priority: 'normal'
|
||||
})
|
||||
|
||||
await feedback.save()
|
||||
|
||||
logger.info(`新反馈提交: ${feedback._id}, 类型: ${type}`)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
feedbackId: feedback._id
|
||||
}, '反馈提交成功,我们会尽快处理')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('提交反馈失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取我的反馈列表
|
||||
* GET /api/feedback/my
|
||||
*/
|
||||
router.get('/my', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
|
||||
const query = { userId: user._id }
|
||||
|
||||
const total = await Feedback.countDocuments(query)
|
||||
const feedbacks = await Feedback.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.select('-deviceInfo -networkInfo')
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedFeedbacks = feedbacks.map(fb => ({
|
||||
id: fb._id,
|
||||
type: fb.type,
|
||||
title: fb.title,
|
||||
content: fb.content.substring(0, 100) + (fb.content.length > 100 ? '...' : ''),
|
||||
images: fb.images,
|
||||
status: fb.status,
|
||||
priority: fb.priority,
|
||||
result: fb.result,
|
||||
createdAt: fb.createdAt,
|
||||
updatedAt: fb.updatedAt
|
||||
}))
|
||||
|
||||
return ApiResponse.paginated(res, formattedFeedbacks, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取我的反馈失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取反馈详情
|
||||
* GET /api/feedback/:id
|
||||
*/
|
||||
router.get('/:id', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const feedback = await Feedback.findById(req.params.id).lean()
|
||||
|
||||
if (!feedback) {
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
// 检查权限(只能查看自己的反馈)
|
||||
if (feedback.userId?.toString() !== user._id.toString()) {
|
||||
return ApiResponse.error(res, '无权查看此反馈', 403)
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
id: feedback._id,
|
||||
type: feedback.type,
|
||||
title: feedback.title,
|
||||
content: feedback.content,
|
||||
images: feedback.images,
|
||||
relatedPage: feedback.relatedPage,
|
||||
status: feedback.status,
|
||||
priority: feedback.priority,
|
||||
processLog: feedback.processLog,
|
||||
result: feedback.result,
|
||||
handler: feedback.handler,
|
||||
handledAt: feedback.handledAt,
|
||||
rating: feedback.rating,
|
||||
createdAt: feedback.createdAt,
|
||||
updatedAt: feedback.updatedAt
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取反馈详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 对处理结果评分
|
||||
* POST /api/feedback/:id/rate
|
||||
*/
|
||||
router.post('/:id/rate', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { rating, remark } = req.body
|
||||
|
||||
if (!rating || rating < 1 || rating > 5) {
|
||||
return ApiResponse.error(res, '评分必须在1-5之间', 400)
|
||||
}
|
||||
|
||||
const feedback = await Feedback.findById(req.params.id)
|
||||
|
||||
if (!feedback) {
|
||||
return ApiResponse.notFound(res, '反馈不存在')
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (feedback.userId?.toString() !== user._id.toString()) {
|
||||
return ApiResponse.error(res, '无权操作此反馈', 403)
|
||||
}
|
||||
|
||||
// 只能对已解决的反馈评分
|
||||
if (feedback.status !== 'resolved' && feedback.status !== 'closed') {
|
||||
return ApiResponse.error(res, '只能对已处理的反馈进行评分', 400)
|
||||
}
|
||||
|
||||
feedback.rating = rating
|
||||
if (remark) {
|
||||
feedback.userRemark = remark
|
||||
}
|
||||
|
||||
await feedback.save()
|
||||
|
||||
return ApiResponse.success(res, {}, '评分成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('反馈评分失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+209
@@ -0,0 +1,209 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Gallery } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 应用用户认证中间件到需要登录的路由
|
||||
// 注意:POST /api/gallery 需要认证
|
||||
router.post('/', userAuthMiddleware)
|
||||
router.use('/like', userAuthMiddleware)
|
||||
// 作品列表接口使用可选认证,这样未登录用户也能访问
|
||||
router.get('/works', optionalAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取作品列表(兼容旧版API)
|
||||
* GET /api/gallery/works
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 12, filter = 'all' } = req.query
|
||||
|
||||
const query = { status: 'approved' }
|
||||
|
||||
// 处理筛选条件
|
||||
if (filter === 'dim2') {
|
||||
query.dim = 2
|
||||
} else if (filter === 'dim3') {
|
||||
query.dim = 3
|
||||
} else if (filter === 'my') {
|
||||
// 显示当前用户的作品,需要认证
|
||||
if (!req.user) {
|
||||
return ApiResponse.error(res, '需要登录才能查看我的作品', 401)
|
||||
}
|
||||
query.openid = req.user.openid
|
||||
}
|
||||
|
||||
const total = await Gallery.countDocuments(query)
|
||||
const works = await Gallery.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedWorks = works.map(work => ({
|
||||
_id: work._id,
|
||||
fileURL: work.imageUrl || work.imageData,
|
||||
authorAvatar: work.authorAvatar || '/static/images/default_avatar.png',
|
||||
authorName: work.authorName || '星辰旅者',
|
||||
likes: work.likeCount || 0,
|
||||
views: work.viewCount || 0,
|
||||
dim: work.dim || 2,
|
||||
type: work.dim === 2 ? 'dim2' : 'dim3',
|
||||
description: work.description || '',
|
||||
createdAt: work.createdAt
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: formattedWorks,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取作品列表
|
||||
* GET /api/gallery
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, status = 'approved', dim } = req.query
|
||||
|
||||
const query = { status }
|
||||
if (dim) query.dim = parseInt(dim)
|
||||
|
||||
const total = await Gallery.countDocuments(query)
|
||||
const works = await Gallery.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
return ApiResponse.paginated(res, works, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传作品
|
||||
* POST /api/gallery
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { title, description, imageData, imageUrl, tags, dim } = req.body
|
||||
|
||||
if (!title || !dim) {
|
||||
return ApiResponse.error(res, '标题和维度为必填项', 400)
|
||||
}
|
||||
|
||||
const work = new Gallery({
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
authorName: user.nickName,
|
||||
authorAvatar: user.avatarUrl,
|
||||
title,
|
||||
description,
|
||||
imageData,
|
||||
imageUrl,
|
||||
tags: tags || [],
|
||||
dim,
|
||||
status: 'approved' // 自动审核通过,用户可以立即看到自己的作品
|
||||
})
|
||||
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, work, '作品上传成功,等待审核')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('上传作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取作品详情
|
||||
* GET /api/gallery/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
work.viewCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, work)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取作品详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 点赞作品(兼容移动端API)
|
||||
* POST /api/gallery/like/:id
|
||||
*/
|
||||
router.post('/like/:id', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
work.likeCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, { likeCount: work.likeCount }, '点赞成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('点赞失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 点赞作品
|
||||
* POST /api/gallery/:id/like
|
||||
*/
|
||||
router.post('/:id/like', async (req, res) => {
|
||||
try {
|
||||
const work = await Gallery.findById(req.params.id)
|
||||
|
||||
if (!work) {
|
||||
return ApiResponse.notFound(res, '作品不存在')
|
||||
}
|
||||
|
||||
work.likeCount += 1
|
||||
await work.save()
|
||||
|
||||
return ApiResponse.success(res, { likeCount: work.likeCount }, '点赞成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('点赞失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
const authRoutes = require('./auth')
|
||||
const userRoutes = require('./user')
|
||||
const paymentRoutes = require('./payment')
|
||||
const galleryRoutes = require('./gallery')
|
||||
const knowledgeRoutes = require('./knowledge')
|
||||
const adminRoutes = require('./admin')
|
||||
const shareRoutes = require('./share')
|
||||
const bgmRoutes = require('./bgm')
|
||||
const aiChatRoutes = require('./aiChat')
|
||||
const aiModelRoutes = require('./aiModel')
|
||||
const feedbackRoutes = require('./feedback')
|
||||
const pinyinRoutes = require('./pinyin')
|
||||
const trendRoutes = require('./trend')
|
||||
|
||||
module.exports = (app) => {
|
||||
// 认证路由
|
||||
app.use('/api/auth', authRoutes)
|
||||
|
||||
// 用户路由
|
||||
app.use('/api/user', userRoutes)
|
||||
|
||||
// 支付路由
|
||||
app.use('/api/payment', paymentRoutes)
|
||||
|
||||
// 画廊路由
|
||||
app.use('/api/gallery', galleryRoutes)
|
||||
|
||||
// 知识库路由
|
||||
app.use('/api/knowledge', knowledgeRoutes)
|
||||
|
||||
// 反馈路由
|
||||
app.use('/api/feedback', feedbackRoutes)
|
||||
|
||||
// 管理后台路由
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
// 分享记录路由
|
||||
app.use('/api/share', shareRoutes)
|
||||
|
||||
// BGM音效路由(公开接口 + 管理员接口)
|
||||
app.use('/api/bgm', bgmRoutes)
|
||||
|
||||
// AI问答路由
|
||||
app.use('/api/ai-chat', aiChatRoutes)
|
||||
|
||||
// AI模型配置路由
|
||||
app.use('/api/ai-model', aiModelRoutes)
|
||||
|
||||
// 拼音探索路由
|
||||
app.use('/api/pinyin/contents', pinyinRoutes.contents)
|
||||
app.use('/api/pinyin/progress', pinyinRoutes.progress)
|
||||
app.use('/api/pinyin/games', pinyinRoutes.games)
|
||||
app.use('/api/pinyin/achievements', pinyinRoutes.achievements)
|
||||
|
||||
// AI趋势路由
|
||||
app.use('/api/trend', trendRoutes)
|
||||
}
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { Knowledge } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取知识列表
|
||||
* GET /api/knowledge
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, dim, category, status = 'approved' } = req.query
|
||||
|
||||
const query = { status }
|
||||
if (dim) query.dim = parseInt(dim)
|
||||
if (category) query.category = category
|
||||
|
||||
const total = await Knowledge.countDocuments(query)
|
||||
const knowledge = await Knowledge.find(query)
|
||||
.sort({ sortOrder: 1, createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
return ApiResponse.paginated(res, knowledge, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取知识列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户收藏的知识列表
|
||||
* GET /api/knowledge/collected
|
||||
*/
|
||||
router.get('/collected', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 查找用户收藏的知识
|
||||
const collectedKnowledge = await Knowledge.find({ _id: { $in: user.collectedKnowledge } })
|
||||
.lean()
|
||||
|
||||
// 格式化返回数据
|
||||
const formattedKnowledge = collectedKnowledge.map(item => ({
|
||||
id: item._id,
|
||||
dimension: item.dim,
|
||||
title: item.title,
|
||||
description: item.content || item.description || '',
|
||||
knowledgeId: item._id
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, formattedKnowledge)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取收藏列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除收藏
|
||||
* DELETE /api/knowledge/collect/:id
|
||||
*/
|
||||
router.delete('/collect/:id', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const knowledgeId = req.params.id
|
||||
const user = req.user
|
||||
|
||||
// 检查是否已经收藏
|
||||
if (!user.collectedKnowledge.includes(knowledgeId)) {
|
||||
return ApiResponse.error(res, '未收藏该知识', 400)
|
||||
}
|
||||
|
||||
// 从用户的 collectedKnowledge 数组中移除
|
||||
user.collectedKnowledge = user.collectedKnowledge.filter(id => id.toString() !== knowledgeId.toString())
|
||||
await user.save()
|
||||
|
||||
// 减少收藏计数
|
||||
const knowledge = await Knowledge.findById(knowledgeId)
|
||||
if (knowledge) {
|
||||
knowledge.collectionCount = Math.max(0, knowledge.collectionCount - 1)
|
||||
await knowledge.save()
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {}, '删除收藏成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('删除收藏失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取知识详情
|
||||
* GET /api/knowledge/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const knowledge = await Knowledge.findById(req.params.id)
|
||||
|
||||
if (!knowledge) {
|
||||
return ApiResponse.notFound(res, '知识不存在')
|
||||
}
|
||||
|
||||
// 增加浏览量
|
||||
knowledge.viewCount += 1
|
||||
await knowledge.save()
|
||||
|
||||
return ApiResponse.success(res, knowledge)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取知识详情失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 收藏知识
|
||||
* POST /api/knowledge/:id/collect
|
||||
*/
|
||||
router.post('/:id/collect', userAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const knowledge = await Knowledge.findById(req.params.id)
|
||||
|
||||
if (!knowledge) {
|
||||
return ApiResponse.notFound(res, '知识不存在')
|
||||
}
|
||||
|
||||
const user = req.user
|
||||
|
||||
// 检查是否已经收藏
|
||||
if (user.collectedKnowledge.includes(knowledge._id)) {
|
||||
return ApiResponse.error(res, '已经收藏过了', 400)
|
||||
}
|
||||
|
||||
// 将知识ID加入用户的 collectedKnowledge 数组
|
||||
user.collectedKnowledge.push(knowledge._id)
|
||||
await user.save()
|
||||
|
||||
// 增加收藏计数
|
||||
knowledge.collectionCount += 1
|
||||
await knowledge.save()
|
||||
|
||||
return ApiResponse.success(res, { collectionCount: knowledge.collectionCount }, '收藏成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('收藏失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取问答
|
||||
* GET /api/knowledge/:dim/questions
|
||||
*/
|
||||
router.get('/:dim/questions', async (req, res) => {
|
||||
try {
|
||||
const { dim } = req.params
|
||||
const { limit = 10 } = req.query
|
||||
|
||||
const questions = await Knowledge.find({
|
||||
dim: parseInt(dim),
|
||||
category: 'question',
|
||||
status: 'approved'
|
||||
})
|
||||
.select('question options')
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
return ApiResponse.success(res, questions)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取问答失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
Executable
+266
@@ -0,0 +1,266 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, ShopItem } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const weChatService = require('../utils/weixin')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 获取商品列表(从数据库)- 公开接口
|
||||
* GET /api/payment/products
|
||||
*/
|
||||
router.get('/products', async (req, res) => {
|
||||
try {
|
||||
const items = await ShopItem.find({ status: 'active' }).sort({ sort: 1 })
|
||||
return ApiResponse.success(res, items)
|
||||
} catch (error) {
|
||||
logger.error('获取商品列表失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// 应用用户认证中间件到需要保护的路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
* POST /api/payment/create-order
|
||||
*/
|
||||
router.post('/create-order', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId } = req.body
|
||||
|
||||
// 从数据库获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, '无效商品 ID 或商品已下架', 400)
|
||||
}
|
||||
|
||||
// 检查是否已购买(针对皮肤类一次性商品)
|
||||
if (product.type === 'skin' && user.ownedSkins?.includes(productId)) {
|
||||
return ApiResponse.error(res, '已拥有该商品', 400)
|
||||
}
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = weChatService.generateOrderNo('WD')
|
||||
|
||||
// 创建订单记录
|
||||
const order = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'pending'
|
||||
})
|
||||
await order.save()
|
||||
|
||||
// 调用微信支付统一下单
|
||||
const payParams = await weChatService.createPaymentOrder({
|
||||
openid: user.openid,
|
||||
orderNo,
|
||||
body: `宇之然-${product.name}`,
|
||||
amount: product.price
|
||||
})
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
orderNo,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
payParams
|
||||
}, '订单创建成功')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('创建订单失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 支付回调
|
||||
* POST /api/payment/callback
|
||||
*/
|
||||
router.post('/callback', async (req, res) => {
|
||||
try {
|
||||
const data = req.body
|
||||
|
||||
// 验证签名
|
||||
if (!weChatService.verifyCallbackSignature(data, data.sign)) {
|
||||
logger.warn('支付签名验证失败')
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
const orderNo = data.out_trade_no
|
||||
const transactionId = data.transaction_id
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
logger.error(`订单不存在: ${orderNo}`)
|
||||
return res.status(400).send('FAIL')
|
||||
}
|
||||
|
||||
// 已处理过
|
||||
if (order.status === 'paid' || order.status === 'completed') {
|
||||
return res.status(200).send('SUCCESS')
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
order.status = 'paid'
|
||||
order.transactionId = transactionId
|
||||
order.paidAt = new Date()
|
||||
await order.save()
|
||||
|
||||
// 解锁商品
|
||||
const user = await User.findOne({ openid: order.openid })
|
||||
if (user) {
|
||||
const product = await ShopItem.findOne({ itemId: order.productId })
|
||||
|
||||
if (product) {
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(order.productId)) {
|
||||
user.ownedSkins.push(order.productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`支付成功: ${orderNo}`)
|
||||
return res.status(200).send('SUCCESS')
|
||||
|
||||
} catch (error) {
|
||||
logger.error('支付回调处理失败:', error)
|
||||
return res.status(500).send('FAIL')
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 验证支付结果
|
||||
* POST /api/payment/verify
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { productId, orderNo } = req.body
|
||||
|
||||
// 验证订单是否已处理
|
||||
const order = await Order.findOne({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
status: { $in: ['paid', 'completed'] }
|
||||
})
|
||||
|
||||
if (order) {
|
||||
return ApiResponse.success(res, {
|
||||
alreadyProcessed: true,
|
||||
message: '订单已处理'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取商品信息
|
||||
const product = await ShopItem.findOne({ itemId: productId, status: 'active' })
|
||||
if (!product) {
|
||||
return ApiResponse.error(res, `未知商品: ${productId}`, 400)
|
||||
}
|
||||
|
||||
// 更新用户商品
|
||||
let updateData = {}
|
||||
|
||||
if (product.type === 'skin') {
|
||||
if (!user.ownedSkins.includes(productId)) {
|
||||
user.ownedSkins.push(productId)
|
||||
}
|
||||
} else if (product.type === 'noad') {
|
||||
user.isAdFree = true
|
||||
} else if (product.type === 'subscription') {
|
||||
const expiry = new Date()
|
||||
expiry.setDate(expiry.getDate() + (product.duration || 30))
|
||||
user.isSubscriber = true
|
||||
user.subscribeExpiry = expiry
|
||||
}
|
||||
|
||||
await user.save()
|
||||
|
||||
// 记录订单
|
||||
const newOrder = new Order({
|
||||
orderNo,
|
||||
openid: user.openid,
|
||||
userId: user._id,
|
||||
productId,
|
||||
productName: product.name,
|
||||
amount: product.price,
|
||||
status: 'completed',
|
||||
completedAt: new Date()
|
||||
})
|
||||
await newOrder.save()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
product,
|
||||
message: `${product.name} 已解锁!`
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('验证支付失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
* POST /api/payment/refund
|
||||
*/
|
||||
router.post('/refund', async (req, res) => {
|
||||
try {
|
||||
const { orderNo, reason } = req.body
|
||||
|
||||
// 查询订单
|
||||
const order = await Order.findOne({ orderNo })
|
||||
if (!order) {
|
||||
return ApiResponse.error(res, '订单不存在', 404)
|
||||
}
|
||||
|
||||
if (order.status !== 'paid' && order.status !== 'completed') {
|
||||
return ApiResponse.error(res, '订单状态不支持退款', 400)
|
||||
}
|
||||
|
||||
// 调用微信退款接口
|
||||
const result = await weChatService.refundOrder(
|
||||
order.transactionId,
|
||||
`REF_${order.orderNo}`,
|
||||
order.amount / 100,
|
||||
order.amount / 100,
|
||||
reason
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
// 更新订单状态
|
||||
order.status = 'refunded'
|
||||
order.refundReason = reason
|
||||
order.refundedAt = new Date()
|
||||
await order.save()
|
||||
|
||||
return ApiResponse.success(res, result, '退款申请已提交')
|
||||
} else {
|
||||
return ApiResponse.error(res, '退款申请失败', 500)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('申请退款失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../../middleware/auth');
|
||||
const achievementController = require('../../controllers/pinyin/achievementController');
|
||||
|
||||
// 获取成就列表(公开)
|
||||
router.get('/', achievementController.getAchievements);
|
||||
|
||||
// 获取我的成就(需要登录)
|
||||
router.get('/my', authMiddleware, achievementController.getMyAchievements);
|
||||
|
||||
// 检查成就达成(需要登录)
|
||||
router.post('/check', authMiddleware, achievementController.checkAchievements);
|
||||
|
||||
module.exports = router;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware } = require('../../middleware/auth');
|
||||
const contentController = require('../../controllers/pinyin/contentController');
|
||||
|
||||
// 公开接口
|
||||
router.get('/', contentController.getContents);
|
||||
router.get('/:symbol', contentController.getContentBySymbol);
|
||||
router.get('/:symbol/audio', contentController.getContentAudio);
|
||||
|
||||
// 管理员接口
|
||||
router.post('/', authMiddleware, contentController.createContent);
|
||||
router.put('/:id', authMiddleware, contentController.updateContent);
|
||||
router.delete('/:id', authMiddleware, contentController.deleteContent);
|
||||
|
||||
module.exports = router;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { userAuthMiddleware } = require('../../middleware/auth');
|
||||
const gameController = require('../../controllers/pinyin/gameController');
|
||||
|
||||
// 获取游戏配置
|
||||
router.get('/config', gameController.getGameConfig);
|
||||
|
||||
// 获取游戏配置(按类型)
|
||||
router.get('/config/:type', gameController.getGameConfigByType);
|
||||
|
||||
// 获取用户游戏统计(需要登录)
|
||||
router.get('/stats', userAuthMiddleware, gameController.getGameStats);
|
||||
|
||||
// 获取用户游戏记录(需要登录)
|
||||
router.get('/records', userAuthMiddleware, gameController.getGameRecords);
|
||||
|
||||
// 记录游戏结果(需要登录)
|
||||
router.post('/record', userAuthMiddleware, gameController.recordGame);
|
||||
|
||||
// 获取游戏排行榜
|
||||
router.get('/leaderboard/:type', gameController.getLeaderboard);
|
||||
|
||||
module.exports = router;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 拼音探索模块路由导出
|
||||
*/
|
||||
const contentsRouter = require('./contents');
|
||||
const progressRouter = require('./progress');
|
||||
const gamesRouter = require('./games');
|
||||
const achievementsRouter = require('./achievements');
|
||||
|
||||
module.exports = {
|
||||
contents: contentsRouter,
|
||||
progress: progressRouter,
|
||||
games: gamesRouter,
|
||||
achievements: achievementsRouter
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { userAuthMiddleware } = require('../../middleware/auth');
|
||||
const progressController = require('../../controllers/pinyin/progressController');
|
||||
|
||||
// 获取用户探索进度
|
||||
router.get('/', userAuthMiddleware, progressController.getProgress);
|
||||
|
||||
module.exports = router;
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { ShareRecord, User } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware, optionalAuthMiddleware, authMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
/**
|
||||
* 记录分享行为(支持匿名)
|
||||
* POST /api/share/record
|
||||
*/
|
||||
router.post('/record', optionalAuthMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { shareType, score } = req.body
|
||||
const openid = req.user ? req.user.openid : 'anonymous'
|
||||
const userId = req.user ? req.user._id : null
|
||||
|
||||
const record = new ShareRecord({
|
||||
openid,
|
||||
userId,
|
||||
shareType: shareType || 'app',
|
||||
score: score || 0
|
||||
})
|
||||
await record.save()
|
||||
|
||||
return ApiResponse.success(res, { message: '分享已记录' })
|
||||
} catch (error) {
|
||||
logger.error('记录分享失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取分享记录列表(管理员)
|
||||
* GET /api/share/list
|
||||
*/
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, openid } = req.query
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const query = {}
|
||||
if (openid && openid !== 'all') {
|
||||
query.openid = openid
|
||||
}
|
||||
|
||||
const records = await ShareRecord.find(query)
|
||||
.sort({ sharedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.populate('userId', 'nickname avatar')
|
||||
|
||||
const total = await ShareRecord.countDocuments(query)
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
list: records,
|
||||
total,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('获取分享记录失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,83 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Trend = require('../models/Trend');
|
||||
|
||||
/**
|
||||
* 获取趋势列表
|
||||
* GET /api/trend?category=产品&page=1&limit=20
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { category, page = 1, limit = 20 } = req.query;
|
||||
const query = { status: 'published' };
|
||||
if (category) query.category = category;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
const [list, total] = await Promise.all([
|
||||
Trend.find(query)
|
||||
.sort({ newsDate: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.select('-__v'),
|
||||
Trend.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { list, total, page: parseInt(page), totalPages: Math.ceil(total / parseInt(limit)) }
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取趋势详情
|
||||
* GET /api/trend/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const trend = await Trend.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ $inc: { viewCount: 1 } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!trend) return res.status(404).json({ success: false, error: '趋势不存在' });
|
||||
res.json({ success: true, data: trend });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 管理员:创建趋势
|
||||
* POST /api/trend/admin
|
||||
*/
|
||||
router.post('/admin', async (req, res) => {
|
||||
try {
|
||||
const trend = new Trend(req.body);
|
||||
await trend.save();
|
||||
res.json({ success: true, data: trend });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 管理员:批量创建趋势(给 cron 用)
|
||||
* POST /api/trend/admin/batch
|
||||
*/
|
||||
router.post('/admin/batch', async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'items 不能为空' });
|
||||
}
|
||||
const result = await Trend.insertMany(items);
|
||||
res.json({ success: true, data: { inserted: result.length } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Executable
+424
@@ -0,0 +1,424 @@
|
||||
const express = require('express')
|
||||
const router = express.Router()
|
||||
const { User, Order, Gallery } = require('../models')
|
||||
const ApiResponse = require('../utils/response')
|
||||
const { userAuthMiddleware } = require('../middleware/auth')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 应用用户认证中间件到所有路由
|
||||
router.use(userAuthMiddleware)
|
||||
|
||||
/**
|
||||
* 获取用户进度
|
||||
* GET /api/user/progress
|
||||
*/
|
||||
router.get('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
if (!user) {
|
||||
// 新用户,返回默认进度
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: true,
|
||||
data: {
|
||||
unlockedDims: [1],
|
||||
dim1Score: 0,
|
||||
dim2Score: 0,
|
||||
dim3Score: 0,
|
||||
dim4Score: 0,
|
||||
dim5Score: 0,
|
||||
totalScore: 0,
|
||||
ownedSkins: [],
|
||||
isAdFree: false,
|
||||
isSubscriber: false,
|
||||
subscribeExpiry: null,
|
||||
totalPlayTime: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
isNewUser: false,
|
||||
data: user
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 保存用户进度
|
||||
* POST /api/user/progress
|
||||
*/
|
||||
router.post('/progress', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const openid = user.openid
|
||||
|
||||
const {
|
||||
dim,
|
||||
data,
|
||||
dim1Score,
|
||||
dim2Score,
|
||||
dim3Score,
|
||||
dim4Score,
|
||||
dim5Score,
|
||||
unlockedDims,
|
||||
ownedSkins,
|
||||
isAdFree,
|
||||
isSubscriber,
|
||||
subscribeExpiry,
|
||||
totalPlayTime
|
||||
} = req.body
|
||||
|
||||
// 查找或创建用户(理论上已通过中间件找到)
|
||||
let dbUser = user
|
||||
|
||||
// 新版接口:按维度更新
|
||||
if (dim && data) {
|
||||
const dimData = dbUser.exploreData[dim] || {}
|
||||
let updatedDim = { ...dimData }
|
||||
|
||||
if (dim === 'dim1') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.collectedEggs = (dimData.collectedEggs || 0) + (data.newEggs || 0)
|
||||
updatedDim.totalLength = Math.max(data.totalLength || 0, dimData.totalLength || 0)
|
||||
} else if (dim === 'dim2') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestArea = Math.max(data.bestArea || 0, dimData.bestArea || 0)
|
||||
updatedDim.createdShapes = (dimData.createdShapes || 0) + (data.newShapes || 0)
|
||||
} else if (dim === 'dim3') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.exploredFaces = Math.max(data.exploredFaces || 0, dimData.exploredFaces || 0)
|
||||
updatedDim.rotationTime = (dimData.rotationTime || 0) + (data.rotationTime || 0)
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
} else if (dim === 'dim4') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredEvents = (dimData.exploredEvents || 0) + (data.newEvents || 0)
|
||||
} else if (dim === 'dim5') {
|
||||
updatedDim.completed = data.completed || dimData.completed || false
|
||||
updatedDim.bestScore = Math.max(data.bestScore || 0, dimData.bestScore || 0)
|
||||
updatedDim.exploredThoughts = (dimData.exploredThoughts || 0) + (data.newThoughts || 0)
|
||||
}
|
||||
|
||||
dbUser.exploreData[dim] = updatedDim
|
||||
|
||||
// 同步更新已解锁维度
|
||||
if (data.completed && !dbUser.unlockedDims.includes(parseInt(dim.replace('dim', '')) + 1)) {
|
||||
const dimNum = parseInt(dim.replace('dim', ''))
|
||||
if (!dbUser.unlockedDims.includes(dimNum + 1)) {
|
||||
dbUser.unlockedDims.push(dimNum + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalPlayTime !== null && totalPlayTime !== undefined) {
|
||||
dbUser.totalPlayTime += totalPlayTime
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版接口:直接传各维度分数
|
||||
if (dim1Score !== null && dim1Score !== undefined) {
|
||||
dbUser.exploreData.dim1.bestScore = Math.max(dim1Score, dbUser.exploreData.dim1?.bestScore || 0)
|
||||
}
|
||||
if (dim2Score !== null && dim2Score !== undefined) {
|
||||
dbUser.exploreData.dim2.bestArea = Math.max(dim2Score, dbUser.exploreData.dim2?.bestArea || 0)
|
||||
}
|
||||
if (dim3Score !== null && dim3Score !== undefined) {
|
||||
dbUser.exploreData.dim3.bestScore = Math.max(dim3Score, dbUser.exploreData.dim3?.bestScore || 0)
|
||||
}
|
||||
if (dim4Score !== null && dim4Score !== undefined) {
|
||||
dbUser.exploreData.dim4 = dbUser.exploreData.dim4 || {}
|
||||
dbUser.exploreData.dim4.bestScore = Math.max(dim4Score, dbUser.exploreData.dim4?.bestScore || 0)
|
||||
}
|
||||
if (dim5Score !== null && dim5Score !== undefined) {
|
||||
dbUser.exploreData.dim5 = dbUser.exploreData.dim5 || {}
|
||||
dbUser.exploreData.dim5.bestScore = Math.max(dim5Score, dbUser.exploreData.dim5?.bestScore || 0)
|
||||
}
|
||||
|
||||
// 更新其他字段
|
||||
if (ownedSkins !== null && ownedSkins !== undefined) dbUser.ownedSkins = ownedSkins
|
||||
if (isAdFree !== null && isAdFree !== undefined) dbUser.isAdFree = isAdFree
|
||||
if (isSubscriber !== null && isSubscriber !== undefined) dbUser.isSubscriber = isSubscriber
|
||||
if (subscribeExpiry !== null && subscribeExpiry !== undefined) dbUser.subscribeExpiry = subscribeExpiry
|
||||
|
||||
await dbUser.save()
|
||||
|
||||
// 计算总分
|
||||
const totalScore = dbUser.calculateTotalScore()
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
success: true,
|
||||
totalScore,
|
||||
exploreData: user.exploreData
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('保存进度失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取排行榜
|
||||
* GET /api/user/leaderboard
|
||||
*/
|
||||
router.get('/leaderboard', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, dim } = req.query
|
||||
|
||||
const users = await User.getLeaderboard(dim ? parseInt(dim) : null, parseInt(limit))
|
||||
|
||||
const data = users.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
openid: u.openid,
|
||||
nickname: u.nickName || '星辰旅者',
|
||||
avatar: u.avatarUrl || '',
|
||||
totalScore: u.totalScore || 0,
|
||||
dim1Score: u.dim1Score || 0,
|
||||
dim2Score: u.dim2Score || 0,
|
||||
dim3Score: u.dim3Score || 0,
|
||||
dim4Score: u.dim4Score || 0,
|
||||
dim5Score: u.dim5Score || 0,
|
||||
unlockedDims: u.unlockedDims || [1]
|
||||
}))
|
||||
|
||||
return ApiResponse.success(res, data)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取排行榜失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户成就
|
||||
* GET /api/user/achievements
|
||||
*/
|
||||
router.get('/achievements', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
|
||||
// 生成成就数据
|
||||
const achievements = [
|
||||
{
|
||||
name: '探索成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'dim1_complete',
|
||||
name: '一维探索者',
|
||||
description: '完成一维空间的探索',
|
||||
icon: '⭐',
|
||||
unlocked: user.exploreData.dim1?.completed || false,
|
||||
date: user.exploreData.dim1?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim2_complete',
|
||||
name: '二维探索者',
|
||||
description: '完成二维空间的探索',
|
||||
icon: '✨',
|
||||
unlocked: user.exploreData.dim2?.completed || false,
|
||||
date: user.exploreData.dim2?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim3_complete',
|
||||
name: '三维探索者',
|
||||
description: '完成三维空间的探索',
|
||||
icon: '🌟',
|
||||
unlocked: user.exploreData.dim3?.completed || false,
|
||||
date: user.exploreData.dim3?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim4_complete',
|
||||
name: '四维探索者',
|
||||
description: '完成四维空间的探索',
|
||||
icon: '💫',
|
||||
unlocked: user.exploreData.dim4?.completed || false,
|
||||
date: user.exploreData.dim4?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'dim5_complete',
|
||||
name: '五维探索者',
|
||||
description: '完成五维空间的探索',
|
||||
icon: '🌌',
|
||||
unlocked: user.exploreData.dim5?.completed || false,
|
||||
date: user.exploreData.dim5?.completed ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '创作成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_work',
|
||||
name: '初次创作',
|
||||
description: '创作你的第一个作品',
|
||||
icon: '🎨',
|
||||
unlocked: user.achievements?.includes('first_work') || false,
|
||||
date: user.achievements?.includes('first_work') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_works',
|
||||
name: '创作达人',
|
||||
description: '创作5个作品',
|
||||
icon: '🏆',
|
||||
unlocked: user.achievements?.includes('five_works') || false,
|
||||
date: user.achievements?.includes('five_works') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'ten_works',
|
||||
name: '创作大师',
|
||||
description: '创作10个作品',
|
||||
icon: '👑',
|
||||
unlocked: user.achievements?.includes('ten_works') || false,
|
||||
date: user.achievements?.includes('ten_works') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '收藏成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_collection',
|
||||
name: '初次收藏',
|
||||
description: '收藏你的第一个知识',
|
||||
icon: '❤️',
|
||||
unlocked: user.collectedKnowledge?.length > 0,
|
||||
date: user.collectedKnowledge?.length > 0 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_collections',
|
||||
name: '收藏爱好者',
|
||||
description: '收藏5个知识',
|
||||
icon: '🧡',
|
||||
unlocked: user.collectedKnowledge?.length >= 5,
|
||||
date: user.collectedKnowledge?.length >= 5 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'ten_collections',
|
||||
name: '收藏大师',
|
||||
description: '收藏10个知识',
|
||||
icon: '💛',
|
||||
unlocked: user.collectedKnowledge?.length >= 10,
|
||||
date: user.collectedKnowledge?.length >= 10 ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '社交成就',
|
||||
achievements: [
|
||||
{
|
||||
id: 'first_share',
|
||||
name: '初次分享',
|
||||
description: '分享你的第一个作品',
|
||||
icon: '📤',
|
||||
unlocked: user.achievements?.includes('first_share') || false,
|
||||
date: user.achievements?.includes('first_share') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
},
|
||||
{
|
||||
id: 'five_shares',
|
||||
name: '分享达人',
|
||||
description: '分享5个作品',
|
||||
icon: '📣',
|
||||
unlocked: user.achievements?.includes('five_shares') || false,
|
||||
date: user.achievements?.includes('five_shares') ? user.updatedAt.toISOString().split('T')[0] : null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return ApiResponse.success(res, achievements)
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取成就失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户统计数据
|
||||
* GET /api/user/stats
|
||||
*/
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { Gallery } = require('../models')
|
||||
|
||||
// 获取用户作品数
|
||||
const worksCount = await Gallery.countDocuments({ userId: user._id })
|
||||
|
||||
// 获取等级信息
|
||||
const levelInfo = user.getLevelInfo()
|
||||
|
||||
// 计算探索时长(分钟转小时,保留1位小数)
|
||||
const playTimeHours = Math.round((user.totalPlayTime || 0) / 60 * 10) / 10
|
||||
|
||||
return ApiResponse.success(res, {
|
||||
level: levelInfo.level,
|
||||
levelName: levelInfo.name,
|
||||
levelPoints: levelInfo.points,
|
||||
levelProgress: levelInfo.progress,
|
||||
totalProgress: levelInfo.totalProgress,
|
||||
nextLevelPoints: levelInfo.nextLevelPoints,
|
||||
totalPlayTime: playTimeHours,
|
||||
totalScore: user.totalScore || 0,
|
||||
worksCount: worksCount,
|
||||
collectionCount: user.collectedKnowledge?.length || 0,
|
||||
achievementCount: user.achievements?.length || 0,
|
||||
unlockedDims: user.unlockedDims || [1]
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户统计失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取用户作品列表
|
||||
* GET /api/user/works
|
||||
*/
|
||||
router.get('/works', async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { page = 1, limit = 20 } = req.query
|
||||
const { Gallery } = require('../models')
|
||||
|
||||
const query = { userId: user._id }
|
||||
|
||||
const total = await Gallery.countDocuments(query)
|
||||
const works = await Gallery.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((parseInt(page) - 1) * parseInt(limit))
|
||||
.limit(parseInt(limit))
|
||||
.lean()
|
||||
|
||||
// 格式化作品数据
|
||||
const formattedWorks = works.map(work => ({
|
||||
id: work._id,
|
||||
title: work.title,
|
||||
description: work.description,
|
||||
imageUrl: work.imageUrl,
|
||||
dimension: work.dimension,
|
||||
status: work.status,
|
||||
likes: work.likes || 0,
|
||||
views: work.views || 0,
|
||||
createdAt: work.createdAt
|
||||
}))
|
||||
|
||||
return ApiResponse.paginated(res, formattedWorks, {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('获取用户作品失败:', error)
|
||||
return ApiResponse.serverError(res, error.message)
|
||||
}
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 批量生成拼音音频URL脚本
|
||||
* 支持多种TTS服务:有道词典、百度语音、科大讯飞等
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 拼音列表
|
||||
const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w'];
|
||||
const finals = ['a','o','e','i','u','ü','ai','ei','ui','ao','ou','iu','ie','üe','er','an','en','in','un','ün','ang','eng','ing','ong'];
|
||||
const overalls = ['zhi','chi','shi','ri','zi','ci','si','yi','wu','yu','ye','yue','yuan','yin','yun','ying'];
|
||||
|
||||
/**
|
||||
* 有道词典TTS服务
|
||||
* 优点:免费、无需注册、支持中文
|
||||
* 缺点:音质一般
|
||||
*/
|
||||
function getYoudaoUrl(text) {
|
||||
// 从环境变量读取TTS基础URL
|
||||
const ttsBaseUrl = process.env.TTS_BASE_URL || 'https://dict.youdao.com/dictvoice';
|
||||
return `${ttsBaseUrl}?audio=${encodeURIComponent(text)}&type=1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成所有拼音的音频URL配置
|
||||
*/
|
||||
function generateAudioConfig() {
|
||||
// 从环境变量读取配置
|
||||
const ttsProvider = process.env.TTS_PROVIDER || 'youdao';
|
||||
const ttsBaseUrl = process.env.TTS_BASE_URL || 'https://dict.youdao.com/dictvoice';
|
||||
|
||||
const config = {
|
||||
// TTS服务提供商
|
||||
provider: ttsProvider,
|
||||
// 基础URL
|
||||
baseUrl: ttsBaseUrl,
|
||||
// 所有拼音的音频URL
|
||||
audios: {}
|
||||
};
|
||||
|
||||
// 声母
|
||||
initials.forEach(symbol => {
|
||||
config.audios[symbol] = {
|
||||
url: getYoudaoUrl(symbol),
|
||||
name: getPinyinName(symbol),
|
||||
type: 'initial'
|
||||
};
|
||||
});
|
||||
|
||||
// 韵母
|
||||
finals.forEach(symbol => {
|
||||
config.audios[symbol] = {
|
||||
url: getYoudaoUrl(symbol),
|
||||
name: getPinyinName(symbol),
|
||||
type: 'final'
|
||||
};
|
||||
});
|
||||
|
||||
// 整体认读
|
||||
overalls.forEach(symbol => {
|
||||
config.audios[symbol] = {
|
||||
url: getYoudaoUrl(symbol),
|
||||
name: getPinyinName(symbol),
|
||||
type: 'overall'
|
||||
};
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拼音名称
|
||||
*/
|
||||
function getPinyinName(symbol) {
|
||||
const nameMap = {
|
||||
'b': '玻', 'p': '坡', 'm': '摸', 'f': '佛',
|
||||
'd': '得', 't': '特', 'n': '讷', 'l': '勒',
|
||||
'g': '哥', 'k': '科', 'h': '喝',
|
||||
'j': '基', 'q': '欺', 'x': '希',
|
||||
'zh': '知', 'ch': '蚩', 'sh': '诗', 'r': '日',
|
||||
'z': '资', 'c': '雌', 's': '思',
|
||||
'y': '医', 'w': '巫',
|
||||
'a': '啊', 'o': '喔', 'e': '鹅', 'i': '衣', 'u': '乌', 'ü': '迂',
|
||||
'ai': '哀', 'ei': '诶', 'ui': '威', 'ao': '熬', 'ou': '欧',
|
||||
'iu': '优', 'ie': '耶', 'üe': '约', 'er': '儿',
|
||||
'an': '安', 'en': '恩', 'in': '因', 'un': '温', 'ün': '晕',
|
||||
'ang': '昂', 'eng': '亨', 'ing': '英', 'ong': '雍',
|
||||
'zhi': '织', 'chi': '吃', 'shi': '狮', 'ri': '日',
|
||||
'zi': '资', 'ci': '疵', 'si': '丝',
|
||||
'yi': '衣', 'wu': '乌', 'yu': '迂',
|
||||
'ye': '耶', 'yue': '约', 'yuan': '冤',
|
||||
'yin': '因', 'yun': '晕', 'ying': '英'
|
||||
};
|
||||
return nameMap[symbol] || symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成SQL更新语句(用于直接更新数据库)
|
||||
*/
|
||||
function generateSqlUpdates() {
|
||||
const config = generateAudioConfig();
|
||||
const sqls = [];
|
||||
|
||||
Object.entries(config.audios).forEach(([symbol, data]) => {
|
||||
const sql = `UPDATE pinyin_contents SET audio_url = '${data.url}' WHERE symbol = '${symbol}';`;
|
||||
sqls.push(sql);
|
||||
});
|
||||
|
||||
return sqls.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成MongoDB更新脚本
|
||||
*/
|
||||
function generateMongoScript() {
|
||||
const config = generateAudioConfig();
|
||||
const updates = [];
|
||||
|
||||
Object.entries(config.audios).forEach(([symbol, data]) => {
|
||||
updates.push({
|
||||
updateOne: {
|
||||
filter: { symbol: symbol },
|
||||
update: { $set: { audioUrl: data.url } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return `db.pinyincontents.bulkWrite(${JSON.stringify(updates, null, 2)});`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置文件
|
||||
*/
|
||||
function saveConfig() {
|
||||
const config = generateAudioConfig();
|
||||
const outputPath = path.join(__dirname, '../../config/pinyin-audio-config.json');
|
||||
|
||||
// 确保目录存在
|
||||
const dir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(config, null, 2));
|
||||
console.log(`✅ 音频配置已保存到: ${outputPath}`);
|
||||
|
||||
// 生成SQL文件
|
||||
const sqlPath = path.join(__dirname, '../../config/update-audio-urls.sql');
|
||||
fs.writeFileSync(sqlPath, generateSqlUpdates());
|
||||
console.log(`✅ SQL更新脚本已保存到: ${sqlPath}`);
|
||||
|
||||
// 生成MongoDB脚本
|
||||
const mongoPath = path.join(__dirname, '../../config/update-audio-urls.js');
|
||||
fs.writeFileSync(mongoPath, generateMongoScript());
|
||||
console.log(`✅ MongoDB更新脚本已保存到: ${mongoPath}`);
|
||||
|
||||
// 输出统计
|
||||
console.log('\n📊 生成统计:');
|
||||
console.log(` - 声母: ${initials.length} 个`);
|
||||
console.log(` - 韵母: ${finals.length} 个`);
|
||||
console.log(` - 整体认读: ${overalls.length} 个`);
|
||||
console.log(` - 总计: ${initials.length + finals.length + overalls.length} 个拼音`);
|
||||
}
|
||||
|
||||
// 运行
|
||||
if (require.main === module) {
|
||||
saveConfig();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateAudioConfig,
|
||||
generateSqlUpdates,
|
||||
generateMongoScript,
|
||||
getYoudaoUrl
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* AI 知识库初始化脚本
|
||||
* 为 5 个 AI 维度填充知识内容
|
||||
*
|
||||
* 用法:
|
||||
* cd /root/Hermes-workspace/yuzhiran-ai-dimension/backend/wdkj-server
|
||||
* node src/scripts/init-knowledge-ai.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const Knowledge = require('../models/Knowledge');
|
||||
|
||||
// 数据库连接
|
||||
const DB_URI = process.env.MONGODB_URI || process.env.DB_URI || 'mongodb://127.0.0.1:27017/wdkj';
|
||||
|
||||
async function main() {
|
||||
console.log('[AI知识初始化] 开始连接数据库...');
|
||||
await mongoose.connect(DB_URI);
|
||||
console.log('[AI知识初始化] 数据库已连接:', DB_URI);
|
||||
|
||||
// 检查现有数据
|
||||
const existing = await Knowledge.countDocuments();
|
||||
if (existing > 0) {
|
||||
console.log(`[AI知识初始化] 发现 ${existing} 条现有记录`);
|
||||
console.log('[AI知识初始化] 继续追加(如需清空请先手动删除)');
|
||||
}
|
||||
|
||||
// 维度数据:key = dim 编号 (1-5)
|
||||
const dimensionData = {
|
||||
1: [
|
||||
{
|
||||
title: '图灵测试:人工智能的哲学起点',
|
||||
content: '1950 年,英国数学家艾伦·图灵发表了划时代的论文《计算机器与智能》,提出了著名的"图灵测试"——如果一台机器能与人类进行对话而不被辨别出机器身份,就可以认为它具备智能。图灵测试不是技术标准,而是哲学思辨:"机器能思考吗?"这个问题开启了 AI 研究的序幕。',
|
||||
dim: 1, category: 'history', tags: ['图灵测试', '艾伦·图灵', 'AI 哲学'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '达特茅斯会议:AI 的诞生',
|
||||
content: '1956 年夏天,约翰·麦卡锡、马文·明斯基、克劳德·香农等科学家在达特茅斯学院聚会,首次提出"Artificial Intelligence"(人工智能)一词,标志着 AI 作为独立学科的正式诞生。会议上定义了 AI 的核心目标:让机器模拟人类智能行为——学习、推理、感知、决策。',
|
||||
dim: 1, category: 'history', tags: ['达特茅斯', '麦卡锡', 'AI 起源'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '深蓝击败卡斯帕罗夫(1997)',
|
||||
content: '1997 年 5 月,IBM 的深蓝(Deep Blue)在国际象棋比赛中以 3.5:2.5 击败世界冠军加里·卡斯帕罗夫。这是 AI 第一次在正式比赛中战胜人类世界冠军,具有里程碑意义。深蓝的胜利展示了暴力搜索和硬编码知识的威力,也引发了对 AI 威胁论的激烈讨论。',
|
||||
dim: 1, category: 'history', tags: ['深蓝', 'IBM', '卡斯帕罗夫'],
|
||||
isPremium: false, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'AlphaGo 与深度学习革命(2016)',
|
||||
content: '2016 年 3 月,Google DeepMind 的 AlphaGo 以 4:1 击败围棋世界冠军李世石。围棋曾被视为人类智力的最后堡垒,AlphaGo 的胜利震惊世界。AlphaGo 结合了深度神经网络和蒙特卡洛树搜索,开创了 AI 的新纪元,直接推动了深度学习在各行各业的爆发式应用。',
|
||||
dim: 1, category: 'history', tags: ['AlphaGo', '深度学习', 'DeepMind'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '从 GPT 到 DeepSeek:大语言模型时代',
|
||||
content: '2018 年 OpenAI 发布 GPT-1,开启了基于 Transformer 的预训练范式。2022 年 ChatGPT 引爆全球,展示了生成式 AI 的惊人能力。2024-2026 年,以 DeepSeek、Llama、Qwen 为代表的开源大模型迅速崛起,AI 真正走进千家万户,成为生产力工具。',
|
||||
dim: 1, category: 'history', tags: ['GPT', 'DeepSeek', '大语言模型'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
2: [
|
||||
{
|
||||
title: '机器学习与深度学习基础',
|
||||
content: '机器学习是 AI 的核心分支,让计算机从数据中自动学习规律,而不需要显式编程。深度学习是机器学习的一个子集,使用多层神经网络来处理复杂模式。从 2012 年 AlexNet 在 ImageNet 夺冠开始,深度学习席卷所有领域。',
|
||||
dim: 2, category: 'concept', tags: ['机器学习', '深度学习', '神经网络'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Transformer:改变一切的架构',
|
||||
content: '2017 年,Google 在论文《Attention Is All You Need》中提出 Transformer 架构,核心是自注意力机制(Self-Attention),让模型可以并行处理序列,突破 RNN 的瓶颈。Transformer 成为后来所有大模型(GPT、BERT、T5)的基础,是 AI 历史上最重要的架构创新之一。',
|
||||
dim: 2, category: 'concept', tags: ['Transformer', '注意力机制', 'Google'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '从 BERT 到 GPT:预训练模型的演进',
|
||||
content: 'BERT(2018)展示了双向编码器的强大,GPT(2018)展示了自回归生成的能力。随着模型规模指数级增长(参数从亿到万亿),涌现出前所未有的能力——这就是规模定律(Scaling Law)。GPT 系列代表了一条路:预训练 + 指令微调 + RLHF,成为今天大语言模型的标准配方。',
|
||||
dim: 2, category: 'application', tags: ['BERT', 'GPT', '预训练'],
|
||||
isPremium: false, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '多模态 AI:从文本到全感官',
|
||||
content: '2022 年后,AI 不再局限于文本。DALL·E、Midjourney、Stable Diffusion 让 AI 绘画普及;Sora、Kling 让 AI 生成视频成为现实;Suno 让 AI 创作音乐。多模态(Multimodal)成为大模型的新标准:一个模型同时理解文本、图像、声音,打通感官壁垒。',
|
||||
dim: 2, category: 'application', tags: ['多模态', '文生图', 'AI 视频'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'MoE 架构:万亿参数的经济之选',
|
||||
content: '混合专家模型(Mixture of Experts, MoE)让模型参数规模达到万亿级别,但推理时只激活部分参数,显著提升性能同时控制成本。GPT-4、Mixtral、DeepSeek 等均已采用 MoE,成为大模型追求性能的终极架构。',
|
||||
dim: 2, category: 'concept', tags: ['MoE', '万亿参数', '架构'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
3: [
|
||||
{
|
||||
title: 'OpenAI vs Anthropic:闭源双雄争霸',
|
||||
content: 'OpenAI 的 GPT-4/4o 系列占据高端市场,Anthropic 的 Claude 3 系列以安全和推理见长。两者代表了闭源大模型的最高水平,但商业化策略不同:OpenAI 走 B2B2C,Anthropic 专注企业市场。',
|
||||
dim: 3, category: 'application', tags: ['OpenAI', 'Anthropic', '闭源模型'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'DeepSeek:中国开源力量的崛起',
|
||||
content: 'DeepSeek(深度求索)是中国 AI 公司的代表,其 DeepSeek-V3 在多项评测中逼近 GPT-4,同时大力开源(DeepSeek-V2、Coder 系列)。DeepSeek 展示了中国团队在大模型领域的技术实力。',
|
||||
dim: 3, category: 'application', tags: ['DeepSeek', '中国 AI', '开源'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '开源 vs 闭源:生态之争',
|
||||
content: '闭源模型(OpenAI、Anthropic)性能领先但成本高、可控性差;开源模型(Llama 3、DeepSeek、Qwen)灵活部署、可定制,生态快速繁荣。2026 年,开源生态已形成完整工具链(vLLM、Ollama、Hugging Face),成为中小企业首选。',
|
||||
dim: 3, category: 'concept', tags: ['开源', '闭源', '生态'],
|
||||
isPremium: false, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Agent 智能体:从 Chatbot 到自主智能',
|
||||
content: 'Agent 是新一代 AI 形态,不仅能对话,还能规划、使用工具、执行任务。AutoGPT、MetaGPT 展示了潜力;Claude 的 Tool Use、OpenAI 的 GPTs 让 Agent 更实用。Agent 是通向 AGI 的关键路径。',
|
||||
dim: 3, category: 'application', tags: ['Agent', '自主智能'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '具身智能与世界模型',
|
||||
content: '具身智能(Embodied AI)让 AI 拥有"身体",能够感知和操作物理世界。Google 的 RT-2、DeepMind 的 RoboCat 展示了机器人与大模型结合的前景。世界模型(World Model)让 AI 理解物理规律,模拟环境,是具身智能的核心。',
|
||||
dim: 3, category: 'concept', tags: ['具身智能', '世界模型', '机器人'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
4: [
|
||||
{
|
||||
title: '提示词工程入门',
|
||||
content: '好的 prompt 能显著提升 AI 输出质量。提示词工程包括:角色设定、任务描述、输出格式、few-shot 示例、思维链(Chain-of-Thought)。学会写 prompt,是使用大模型的基本功。',
|
||||
dim: 4, category: 'concept', tags: ['提示词', 'Prompt Engineering'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '检索增强生成(RAG)详解',
|
||||
content: 'RAG (Retrieval-Augmented Generation) 将外部知识库与 LLM 结合,解决大模型知识滞后和幻觉问题。流程:用户提问 → 向量检索相关文档 → 拼接 prompt → LLM 生成答案。RAG 是企业知识库 AI 化的核心技术。',
|
||||
dim: 4, category: 'concept', tags: ['RAG', '检索增强', '知识库'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: '模型微调(Fine-tuning)实战',
|
||||
content: '微调是在预训练模型基础上用领域数据进一步训练,让模型适应特定任务。常用方法:全量微调、LoRA(低秩适应)、QLoRA(量化 LoRA)。小数据也能做微调,但要注意过拟合和灾难性遗忘。',
|
||||
dim: 4, category: 'application', tags: ['Fine-tuning', 'LoRA', '微调'],
|
||||
isPremium: true, sortOrder: 3, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'LangChain:AI 应用开发框架',
|
||||
content: 'LangChain 是最流行的 LLM 应用框架,提供模型调用、提示模板、记忆管理、工具链、Agent 等模块,大大降低开发难度。用它快速搭建 RAG 应用、聊天机器人、数据分析工具。',
|
||||
dim: 4, category: 'application', tags: ['LangChain', '开发框架'],
|
||||
isPremium: true, sortOrder: 4, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Dify:无代码 AI 平台',
|
||||
content: 'Dify 是开源的 LLMOps 平台,可视化编排 AI 工作流,支持 RAG、Agent、知识库,无需写代码即可构建企业级 AI 应用。适合业务人员快速落地 AI 解决方案。',
|
||||
dim: 4, category: 'application', tags: ['Dify', '低代码'],
|
||||
isPremium: true, sortOrder: 5, status: 'approved'
|
||||
}
|
||||
],
|
||||
|
||||
5: [
|
||||
{
|
||||
title: 'WAIC 2026:世界人工智能大会即将开幕',
|
||||
content: '2026 年世界人工智能大会(WAIC)将于 7 月 17 日在上海开幕,预计将有 1100+ 企业参展,发布众多前沿技术和产品。WAIC 是了解 AI 趋势的绝佳窗口。',
|
||||
dim: 5, category: 'concept', tags: ['WAIC', '会议'],
|
||||
isPremium: false, sortOrder: 1, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'AI 法规与伦理:全球监管趋势',
|
||||
content: '各国正在加快 AI 立法:欧盟 AI Act 已生效,美国 NIST AI RMF 发布,中国《生成式 AI 服务管理暂行办法》持续完善。安全、可控、可信成为 AI 发展的重要议题。',
|
||||
dim: 5, category: 'concept', tags: ['AI 伦理', '法规'],
|
||||
isPremium: false, sortOrder: 2, status: 'approved'
|
||||
},
|
||||
{
|
||||
title: 'Agent 将如何改变软件交互方式?',
|
||||
content: 'Agent 让软件从"人找功能"变为"AI 帮我做事"。未来的办公软件、设计工具、数据分析平台都将以 Agent 为核心交互。一些专家预测:2027 年 Agent 将替代 30% 的重复性办公任务。',
|
||||
dim: 5, category: 'application', tags: ['Agent', '未来趋势'],
|
||||
isPremium: true, sortOrder: 3, status: 'approved'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const dims = [1, 2, 3, 4, 5];
|
||||
let totalInserted = 0;
|
||||
|
||||
for (const dim of dims) {
|
||||
const entries = dimensionData[dim];
|
||||
if (!entries) continue;
|
||||
console.log(`[维度${dim}] 开始插入 ${entries.length} 条知识...`);
|
||||
try {
|
||||
const result = await Knowledge.insertMany(entries);
|
||||
totalInserted += result.length;
|
||||
console.log(`[维度${dim}] 已插入 ${result.length} 条`);
|
||||
} catch (err) {
|
||||
console.error(`[维度${dim}] 插入失败:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const total = await Knowledge.countDocuments();
|
||||
console.log(`✅ 完成!Knowledge 库现有 ${total} 条记录(本次新增 ${totalInserted} 条)`);
|
||||
await mongoose.disconnect();
|
||||
console.log('数据库连接已关闭');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ 初始化失败:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 拼音数据初始化脚本
|
||||
* 创建所有63个拼音的基础数据
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const { PinyinContent } = require('../models/pinyin');
|
||||
const { PinyinAchievement } = require('../models/pinyin');
|
||||
|
||||
// 声母表 (23个)
|
||||
const initials = [
|
||||
{ symbol: 'b', name: '玻', order: 1 },
|
||||
{ symbol: 'p', name: '坡', order: 2 },
|
||||
{ symbol: 'm', name: '摸', order: 3 },
|
||||
{ symbol: 'f', name: '佛', order: 4 },
|
||||
{ symbol: 'd', name: '得', order: 5 },
|
||||
{ symbol: 't', name: '特', order: 6 },
|
||||
{ symbol: 'n', name: '讷', order: 7 },
|
||||
{ symbol: 'l', name: '勒', order: 8 },
|
||||
{ symbol: 'g', name: '哥', order: 9 },
|
||||
{ symbol: 'k', name: '科', order: 10 },
|
||||
{ symbol: 'h', name: '喝', order: 11 },
|
||||
{ symbol: 'j', name: '基', order: 12 },
|
||||
{ symbol: 'q', name: '欺', order: 13 },
|
||||
{ symbol: 'x', name: '希', order: 14 },
|
||||
{ symbol: 'zh', name: '知', order: 15 },
|
||||
{ symbol: 'ch', name: '蚩', order: 16 },
|
||||
{ symbol: 'sh', name: '诗', order: 17 },
|
||||
{ symbol: 'r', name: '日', order: 18 },
|
||||
{ symbol: 'z', name: '资', order: 19 },
|
||||
{ symbol: 'c', name: '雌', order: 20 },
|
||||
{ symbol: 's', name: '思', order: 21 },
|
||||
{ symbol: 'y', name: '医', order: 22 },
|
||||
{ symbol: 'w', name: '巫', order: 23 }
|
||||
];
|
||||
|
||||
// 韵母表 (24个)
|
||||
const finals = [
|
||||
{ symbol: 'a', name: '啊', order: 1 },
|
||||
{ symbol: 'o', name: '喔', order: 2 },
|
||||
{ symbol: 'e', name: '鹅', order: 3 },
|
||||
{ symbol: 'i', name: '衣', order: 4 },
|
||||
{ symbol: 'u', name: '乌', order: 5 },
|
||||
{ symbol: 'ü', name: '迂', order: 6 },
|
||||
{ symbol: 'ai', name: '哀', order: 7 },
|
||||
{ symbol: 'ei', name: '诶', order: 8 },
|
||||
{ symbol: 'ui', name: '威', order: 9 },
|
||||
{ symbol: 'ao', name: '熬', order: 10 },
|
||||
{ symbol: 'ou', name: '欧', order: 11 },
|
||||
{ symbol: 'iu', name: '优', order: 12 },
|
||||
{ symbol: 'ie', name: '耶', order: 13 },
|
||||
{ symbol: 'üe', name: '约', order: 14 },
|
||||
{ symbol: 'er', name: '儿', order: 15 },
|
||||
{ symbol: 'an', name: '安', order: 16 },
|
||||
{ symbol: 'en', name: '恩', order: 17 },
|
||||
{ symbol: 'in', name: '因', order: 18 },
|
||||
{ symbol: 'un', name: '温', order: 19 },
|
||||
{ symbol: 'ün', name: '晕', order: 20 },
|
||||
{ symbol: 'ang', name: '昂', order: 21 },
|
||||
{ symbol: 'eng', name: '亨', order: 22 },
|
||||
{ symbol: 'ing', name: '英', order: 23 },
|
||||
{ symbol: 'ong', name: '雍', order: 24 }
|
||||
];
|
||||
|
||||
// 整体认读音节 (16个)
|
||||
const overalls = [
|
||||
{ symbol: 'zhi', name: '织', order: 1 },
|
||||
{ symbol: 'chi', name: '吃', order: 2 },
|
||||
{ symbol: 'shi', name: '狮', order: 3 },
|
||||
{ symbol: 'ri', name: '日', order: 4 },
|
||||
{ symbol: 'zi', name: '资', order: 5 },
|
||||
{ symbol: 'ci', name: '疵', order: 6 },
|
||||
{ symbol: 'si', name: '丝', order: 7 },
|
||||
{ symbol: 'yi', name: '衣', order: 8 },
|
||||
{ symbol: 'wu', name: '乌', order: 9 },
|
||||
{ symbol: 'yu', name: '迂', order: 10 },
|
||||
{ symbol: 'ye', name: '耶', order: 11 },
|
||||
{ symbol: 'yue', name: '约', order: 12 },
|
||||
{ symbol: 'yuan', name: '冤', order: 13 },
|
||||
{ symbol: 'yin', name: '因', order: 14 },
|
||||
{ symbol: 'yun', name: '晕', order: 15 },
|
||||
{ symbol: 'ying', name: '英', order: 16 }
|
||||
];
|
||||
|
||||
// 示例词语数据
|
||||
const sampleWords = {
|
||||
'b': [
|
||||
{ word: '爸爸', pinyin: 'bàba', meaning: '父亲' },
|
||||
{ word: '杯子', pinyin: 'bēizi', meaning: '装水的器具' },
|
||||
{ word: '白云', pinyin: 'báiyún', meaning: '白色的云' }
|
||||
],
|
||||
'a': [
|
||||
{ word: '妈妈', pinyin: 'māma', meaning: '母亲' },
|
||||
{ word: '阿姨', pinyin: 'āyí', meaning: '母亲的姐妹' }
|
||||
],
|
||||
'zhi': [
|
||||
{ word: '知道', pinyin: 'zhīdào', meaning: '了解' },
|
||||
{ word: '蜘蛛', pinyin: 'zhīzhū', meaning: '一种昆虫' }
|
||||
]
|
||||
};
|
||||
|
||||
// 发音方法说明
|
||||
const pronunciationGuides = {
|
||||
'b': '双唇紧闭,阻碍气流,然后双唇突然放开,让气流冲出,读音轻短。',
|
||||
'p': '双唇紧闭,阻碍气流,然后双唇突然放开,气流较强地冲出。',
|
||||
'm': '双唇紧闭,软腭下降,气流从鼻腔出来,声带振动。',
|
||||
'f': '上齿接触下唇,形成缝隙,气流从缝隙中摩擦出来。',
|
||||
'a': '嘴巴张大,舌头放平,舌位低,声音响亮。',
|
||||
'o': '嘴巴圆圆,舌头后缩,舌位半高。'
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成音频URL(从环境变量读取TTS配置)
|
||||
*/
|
||||
function getAudioUrl(symbol) {
|
||||
// 从环境变量读取TTS基础URL,默认使用有道词典
|
||||
const ttsBaseUrl = process.env.TTS_BASE_URL || 'https://dict.youdao.com/dictvoice';
|
||||
const ttsProvider = process.env.TTS_PROVIDER || 'youdao';
|
||||
|
||||
// 如果使用自定义CDN
|
||||
if (ttsProvider === 'custom' && process.env.TTS_CUSTOM_CDN_URL) {
|
||||
return `${process.env.TTS_CUSTOM_CDN_URL}/${encodeURIComponent(symbol)}.mp3`;
|
||||
}
|
||||
|
||||
// 默认使用有道词典格式
|
||||
return `${ttsBaseUrl}?audio=${encodeURIComponent(symbol)}&type=1`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成口型图URL(从环境变量读取CDN配置)
|
||||
*/
|
||||
function getMouthImage(symbol) {
|
||||
// 从环境变量读取口型图CDN
|
||||
const mouthImageCdn = process.env.MOUTH_IMAGE_CDN_URL;
|
||||
|
||||
if (mouthImageCdn) {
|
||||
return `${mouthImageCdn}/mouth_${encodeURIComponent(symbol)}.png`;
|
||||
}
|
||||
|
||||
// 默认返回空,由管理后台上传
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建拼音内容数据
|
||||
*/
|
||||
async function createPinyinContents() {
|
||||
const contents = [];
|
||||
|
||||
// 处理声母
|
||||
for (const item of initials) {
|
||||
const words = sampleWords[item.symbol] || [
|
||||
{ word: item.name, pinyin: item.symbol, meaning: '示例词语' }
|
||||
];
|
||||
|
||||
contents.push({
|
||||
symbol: item.symbol,
|
||||
type: 'initial',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
audioUrl: getAudioUrl(item.symbol),
|
||||
mouthImage: getMouthImage(item.symbol),
|
||||
pronunciation: pronunciationGuides[item.symbol] || `${item.name}的发音`,
|
||||
isFree: item.order <= 5, // 前5个免费
|
||||
words: words.map(w => ({
|
||||
...w,
|
||||
audioUrl: getAudioUrl(w.word),
|
||||
image: ''
|
||||
})),
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理韵母
|
||||
for (const item of finals) {
|
||||
const words = sampleWords[item.symbol] || [
|
||||
{ word: item.name, pinyin: item.symbol, meaning: '示例词语' }
|
||||
];
|
||||
|
||||
contents.push({
|
||||
symbol: item.symbol,
|
||||
type: 'final',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
audioUrl: getAudioUrl(item.symbol),
|
||||
mouthImage: getMouthImage(item.symbol),
|
||||
pronunciation: pronunciationGuides[item.symbol] || `${item.name}的发音`,
|
||||
isFree: item.order <= 3, // 前3个免费
|
||||
words: words.map(w => ({
|
||||
...w,
|
||||
audioUrl: getAudioUrl(w.word),
|
||||
image: ''
|
||||
})),
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理整体认读
|
||||
for (const item of overalls) {
|
||||
const words = sampleWords[item.symbol] || [
|
||||
{ word: item.name, pinyin: item.symbol, meaning: '示例词语' }
|
||||
];
|
||||
|
||||
contents.push({
|
||||
symbol: item.symbol,
|
||||
type: 'overall',
|
||||
name: item.name,
|
||||
order: item.order,
|
||||
audioUrl: getAudioUrl(item.symbol),
|
||||
mouthImage: getMouthImage(item.symbol),
|
||||
pronunciation: pronunciationGuides[item.symbol] || `${item.name}的发音`,
|
||||
isFree: item.order <= 2, // 前2个免费
|
||||
words: words.map(w => ({
|
||||
...w,
|
||||
audioUrl: getAudioUrl(w.word),
|
||||
image: ''
|
||||
})),
|
||||
exploreAreas: {
|
||||
audio: true,
|
||||
mouth: true,
|
||||
speak: true,
|
||||
write: true,
|
||||
game: true,
|
||||
words: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成就数据
|
||||
*/
|
||||
async function createAchievements() {
|
||||
const achievements = [
|
||||
{
|
||||
code: 'first_explore',
|
||||
name: '初次探索',
|
||||
description: '完成第一次拼音探索',
|
||||
type: 'explore',
|
||||
icon: 'rocket',
|
||||
condition: { type: 'explore_count', value: 1 },
|
||||
reward: { type: 'stone', value: 1 },
|
||||
order: 1,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'explore_10',
|
||||
name: '探索新手',
|
||||
description: '累计探索10个拼音',
|
||||
type: 'explore',
|
||||
icon: 'star',
|
||||
condition: { type: 'explore_count', value: 10 },
|
||||
reward: { type: 'stone', value: 5 },
|
||||
order: 2,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'explore_50',
|
||||
name: '探索达人',
|
||||
description: '累计探索50个拼音',
|
||||
type: 'explore',
|
||||
icon: 'trophy',
|
||||
condition: { type: 'explore_count', value: 50 },
|
||||
reward: { type: 'stone', value: 20 },
|
||||
order: 3,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'collect_10',
|
||||
name: '收集者',
|
||||
description: '收集10个能量石',
|
||||
type: 'collection',
|
||||
icon: 'gem',
|
||||
condition: { type: 'collect_count', value: 10 },
|
||||
reward: { type: 'stone', value: 5 },
|
||||
order: 4,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'collect_all',
|
||||
name: '收集大师',
|
||||
description: '收集所有63个能量石',
|
||||
type: 'collection',
|
||||
icon: 'crown',
|
||||
condition: { type: 'collect_count', value: 63 },
|
||||
reward: { type: 'stone', value: 50 },
|
||||
order: 5,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'streak_7',
|
||||
name: '坚持一周',
|
||||
description: '连续7天进行拼音探索',
|
||||
type: 'streak',
|
||||
icon: 'fire',
|
||||
condition: { type: 'streak_days', value: 7 },
|
||||
reward: { type: 'stone', value: 10 },
|
||||
order: 6,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'complete_symbol_b',
|
||||
name: 'b的探索者',
|
||||
description: '完成拼音b的所有探索',
|
||||
type: 'special',
|
||||
icon: 'check-circle',
|
||||
condition: { type: 'complete_symbol', value: 1, symbol: 'b' },
|
||||
reward: { type: 'stone', value: 2 },
|
||||
order: 7,
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
code: 'game_master',
|
||||
name: '游戏高手',
|
||||
description: '趣味互动累计获得1000分',
|
||||
type: 'special',
|
||||
icon: 'gamepad',
|
||||
condition: { type: 'game_score', value: 1000 },
|
||||
reward: { type: 'stone', value: 15 },
|
||||
order: 8,
|
||||
isActive: true
|
||||
}
|
||||
];
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化拼音数据
|
||||
*/
|
||||
async function initPinyinData() {
|
||||
try {
|
||||
// 连接数据库
|
||||
const mongoURI = process.env.MONGODB_URI ||
|
||||
`mongodb://${process.env.MONGODB_USER}:${process.env.MONGODB_PASSWORD}@${process.env.MONGODB_HOST}:${process.env.MONGODB_PORT}/${process.env.MONGODB_DB}?authSource=${process.env.MONGODB_DB}`;
|
||||
await mongoose.connect(mongoURI);
|
||||
console.log('数据库连接成功');
|
||||
|
||||
// 清空现有数据(可选,生产环境慎用)
|
||||
const clearExisting = process.env.CLEAR_EXISTING === 'true';
|
||||
if (clearExisting) {
|
||||
await PinyinContent.deleteMany({});
|
||||
await PinyinAchievement.deleteMany({});
|
||||
console.log('已清空现有数据');
|
||||
}
|
||||
|
||||
// 检查是否已有数据
|
||||
const existingCount = await PinyinContent.countDocuments();
|
||||
if (existingCount > 0 && !clearExisting) {
|
||||
console.log(`数据库中已有 ${existingCount} 条拼音数据,跳过初始化`);
|
||||
console.log('如需重新初始化,请设置环境变量 CLEAR_EXISTING=true');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 创建拼音内容
|
||||
const contents = await createPinyinContents();
|
||||
await PinyinContent.insertMany(contents);
|
||||
console.log(`成功创建 ${contents.length} 个拼音内容`);
|
||||
|
||||
// 创建成就
|
||||
const achievements = await createAchievements();
|
||||
await PinyinAchievement.insertMany(achievements);
|
||||
console.log(`成功创建 ${achievements.length} 个成就`);
|
||||
|
||||
console.log('拼音数据初始化完成!');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('初始化失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 运行初始化
|
||||
if (require.main === module) {
|
||||
initPinyinData();
|
||||
}
|
||||
|
||||
module.exports = { initPinyinData, createPinyinContents, createAchievements };
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 更新商品定价为 AI 维度主题
|
||||
* 用法: node src/scripts/update-products.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const ShopItem = require('../models/ShopItem');
|
||||
|
||||
const DB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wdkj';
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(DB_URI);
|
||||
console.log('数据库已连接');
|
||||
|
||||
// 清空旧商品
|
||||
await ShopItem.deleteMany({});
|
||||
console.log('旧商品已清空');
|
||||
|
||||
const products = [
|
||||
{
|
||||
itemId: 'pro_monthly',
|
||||
name: 'AI维度 Pro (月付)',
|
||||
description: '无限 AI 问答 + 全部知识解锁 + 趋势跟踪',
|
||||
price: 1990, // 分 = ¥19.90
|
||||
originalPrice: 2990,
|
||||
type: 'subscription',
|
||||
duration: 30,
|
||||
sortOrder: 1,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'pro_quarterly',
|
||||
name: 'AI维度 Pro (季付)',
|
||||
description: '无限 AI 问答 + 全部知识解锁 + 趋势跟踪,享 8 折',
|
||||
price: 4990, // ¥49.90
|
||||
originalPrice: 5970,
|
||||
type: 'subscription',
|
||||
duration: 90,
|
||||
sortOrder: 2,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'vip_monthly',
|
||||
name: 'AI维度 VIP (月付)',
|
||||
description: 'Pro + 工具教程 + 学习路径定制 + 社区 + 优先支持',
|
||||
price: 3990, // ¥39.90
|
||||
originalPrice: 5990,
|
||||
type: 'subscription',
|
||||
duration: 30,
|
||||
sortOrder: 3,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'vip_quarterly',
|
||||
name: 'AI维度 VIP (季付)',
|
||||
description: 'VIP全套权益,享 8 折优惠',
|
||||
price: 9990, // ¥99.90
|
||||
originalPrice: 11970,
|
||||
type: 'subscription',
|
||||
duration: 90,
|
||||
sortOrder: 4,
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
itemId: 'noad',
|
||||
name: '去广告',
|
||||
description: '永久去除广告,纯净体验',
|
||||
price: 1200, // ¥12.00
|
||||
type: 'noad',
|
||||
duration: 0,
|
||||
sortOrder: 5,
|
||||
status: 'active'
|
||||
}
|
||||
];
|
||||
|
||||
const result = await ShopItem.insertMany(products);
|
||||
console.log(`✅ 已插入 ${result.length} 个商品:`);
|
||||
result.forEach(p => console.log(` - ${p.name}: ¥${(p.price/100).toFixed(2)}`));
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('❌', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 趋势数据更新脚本(兼容 Hermes cron 调用)
|
||||
* 从 AI 新闻源获取最新趋势并写入数据库
|
||||
*
|
||||
* 用法: node src/scripts/update-trends.js
|
||||
* 或由 Hermes cron 每周调用
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mongoose = require('mongoose');
|
||||
const Trend = require('../models/Trend');
|
||||
|
||||
const DB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/wdkj';
|
||||
|
||||
// 示例新闻数据(生产环境可从 RSS/API 获取)
|
||||
const sampleTrends = [
|
||||
{
|
||||
title: 'DeepSeek 秘密造芯:推理芯片项目已启动一年',
|
||||
summary: '据报道,DeepSeek 正在自研推理芯片,旨在降低对英伟达的依赖。',
|
||||
source: '路透社', sourceUrl: 'https://reuters.com',
|
||||
category: '公司', tags: ['DeepSeek', '芯片'], hot: true, newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: '蚂蚁灵波开源 LingBot-World 2.0,世界模型小时级实时生成',
|
||||
summary: 'LingBot-World 2.0 实现了世界模型的小时级实时生成,是具身智能的重要突破。',
|
||||
source: '量子位', sourceUrl: 'https://qbitai.com',
|
||||
category: '产品', tags: ['蚂蚁灵波', '世界模型'], newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: '阿里获 ACL 2026 最佳资源论文奖,揭示 Agent 结构性缺陷',
|
||||
summary: '阿里研究团队获得 ACL 2026 最佳资源论文奖,深入分析了当前 Agent 系统的结构性缺陷。',
|
||||
source: '机器之心', sourceUrl: 'https://jiqizhixin.com',
|
||||
category: '论文', tags: ['阿里', 'ACL', 'Agent'], newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: '腾讯混元 Hy3 正式上线,Agent 任务解决率跃升至 90%',
|
||||
summary: '腾讯混元大模型 Hy3 版本正式上线,在 Agent 任务评测中解决率提升至 90%。',
|
||||
source: '腾讯云', sourceUrl: 'https://cloud.tencent.com',
|
||||
category: '产品', tags: ['腾讯', '混元', 'Agent'], newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: 'WAIC 2026 倒计时:7月17日上海开幕,1100+企业参展',
|
||||
summary: '2026 世界人工智能大会即将在上海开幕,将有超过 1100 家企业参展。',
|
||||
source: '量子位', sourceUrl: 'https://qbitai.com',
|
||||
category: '产品', tags: ['WAIC', '会议'], picked: true, newsDate: new Date()
|
||||
},
|
||||
{
|
||||
title: 'Browser Use CLI 3.0 发布:体积缩小 6 倍,Token 消耗大幅降低',
|
||||
summary: 'Browser Use 工具 CLI 3.0 版本发布,体积缩小 6 倍,Token 消耗显著降低。',
|
||||
source: 'GitHub', sourceUrl: 'https://github.com',
|
||||
category: '工具', tags: ['Browser Use', '开源'], newsDate: new Date()
|
||||
}
|
||||
];
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(DB_URI);
|
||||
console.log('数据库已连接');
|
||||
|
||||
// 清除 7 天前的旧数据
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const deleted = await Trend.deleteMany({ newsDate: { $lt: weekAgo } });
|
||||
console.log(`已清理 ${deleted.deletedCount} 条旧数据`);
|
||||
|
||||
// 插入新数据(去重)
|
||||
let inserted = 0;
|
||||
for (const item of sampleTrends) {
|
||||
const exists = await Trend.findOne({ title: item.title });
|
||||
if (!exists) {
|
||||
await new Trend({ ...item, status: 'published' }).save();
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
console.log(`新增 ${inserted} 条趋势数据`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('❌', e.message); process.exit(1); });
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* 日志级别
|
||||
*/
|
||||
const LOG_LEVELS = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志器
|
||||
*/
|
||||
class Logger {
|
||||
constructor(level = 'info', logDir = './logs') {
|
||||
this.level = LOG_LEVELS[level] || LOG_LEVELS.info
|
||||
this.logDir = logDir
|
||||
|
||||
// 确保日志目录存在
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志消息
|
||||
*/
|
||||
formatMessage(level, message, meta = {}) {
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = Object.keys(meta).length > 0 ? JSON.stringify(meta) : ''
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message} ${metaStr}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入日志文件
|
||||
*/
|
||||
writeToFile(level, message, meta) {
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const logFile = path.join(this.logDir, `${date}.log`)
|
||||
const logMessage = this.formatMessage(level, message, meta)
|
||||
|
||||
fs.appendFile(logFile, logMessage, (err) => {
|
||||
if (err) console.error('写入日志失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录日志
|
||||
*/
|
||||
log(level, message, meta = {}) {
|
||||
if (LOG_LEVELS[level] < this.level) return
|
||||
|
||||
const formattedMessage = this.formatMessage(level, message, meta)
|
||||
|
||||
// 控制台输出
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(formattedMessage.trim())
|
||||
break
|
||||
case 'warn':
|
||||
console.warn(formattedMessage.trim())
|
||||
break
|
||||
default:
|
||||
console.log(formattedMessage.trim())
|
||||
}
|
||||
|
||||
// 文件输出(生产环境)
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
this.writeToFile(level, message, meta)
|
||||
}
|
||||
}
|
||||
|
||||
debug(message, meta) {
|
||||
this.log('debug', message, meta)
|
||||
}
|
||||
|
||||
info(message, meta) {
|
||||
this.log('info', message, meta)
|
||||
}
|
||||
|
||||
warn(message, meta) {
|
||||
this.log('warn', message, meta)
|
||||
}
|
||||
|
||||
error(message, meta) {
|
||||
this.log('error', message, meta)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const logger = new Logger(process.env.LOG_LEVEL || 'info')
|
||||
|
||||
module.exports = logger
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 统一响应格式
|
||||
*/
|
||||
class ApiResponse {
|
||||
/**
|
||||
* 成功响应
|
||||
*/
|
||||
static success(res, data = null, message = '操作成功', statusCode = 200) {
|
||||
return res.status(statusCode).json({
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
*/
|
||||
static error(res, error = '操作失败', statusCode = 400, details = null) {
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error,
|
||||
details,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
*/
|
||||
static paginated(res, items, pagination, message = '查询成功') {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message,
|
||||
data: {
|
||||
items,
|
||||
pagination: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
total: pagination.total,
|
||||
totalPages: Math.ceil(pagination.total / pagination.limit)
|
||||
}
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 未授权响应
|
||||
*/
|
||||
static unauthorized(res, error = '未授权访问') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁止访问响应
|
||||
*/
|
||||
static forbidden(res, error = '权限不足') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在响应
|
||||
*/
|
||||
static notFound(res, error = '资源不存在') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器错误响应
|
||||
*/
|
||||
static serverError(res, error = '服务器内部错误') {
|
||||
console.error('Server Error:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiResponse
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
const axios = require('axios')
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
/**
|
||||
* 微信小程序 API 工具类
|
||||
*/
|
||||
class WeChatService {
|
||||
constructor() {
|
||||
this.appId = process.env.WX_APPID
|
||||
this.secret = process.env.WX_SECRET
|
||||
this.mchId = process.env.WX_PAY_MCH_ID
|
||||
this.apiKey = process.env.WX_PAY_API_KEY // APIv3 Key
|
||||
this.serialNo = process.env.WX_PAY_SERIAL_NO
|
||||
this.notifyUrl = process.env.WX_PAY_NOTIFY_URL
|
||||
|
||||
// 加载证书
|
||||
try {
|
||||
const certPath = path.join(__dirname, '../../certs/apiclient_cert.pem')
|
||||
const keyPath = path.join(__dirname, '../../certs/apiclient_key.pem')
|
||||
this.privateKey = fs.readFileSync(keyPath)
|
||||
this.certificate = fs.readFileSync(certPath)
|
||||
} catch (error) {
|
||||
console.error('微信支付证书加载失败,请检查 certs 目录:', error.message)
|
||||
}
|
||||
|
||||
// 缓存 access_token
|
||||
this.accessToken = null
|
||||
this.tokenExpireTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Access Token
|
||||
*/
|
||||
async getAccessToken() {
|
||||
// 检查缓存
|
||||
if (this.accessToken && Date.now() < this.tokenExpireTime) {
|
||||
return this.accessToken
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
|
||||
params: {
|
||||
grant_type: 'client_credential',
|
||||
appid: this.appId,
|
||||
secret: this.secret
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.errcode) {
|
||||
throw new Error(response.data.errmsg)
|
||||
}
|
||||
|
||||
this.accessToken = response.data.access_token
|
||||
this.tokenExpireTime = Date.now() + (response.data.expires_in - 300) * 1000 // 提前5分钟过期
|
||||
|
||||
return this.accessToken
|
||||
} catch (error) {
|
||||
console.error('获取 Access Token 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 code 换取 openid 和 session_key
|
||||
*/
|
||||
async code2Session(code) {
|
||||
try {
|
||||
const response = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
|
||||
params: {
|
||||
appid: this.appId,
|
||||
secret: this.secret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.errcode && response.data.errcode !== 0) {
|
||||
throw new Error(response.data.errmsg)
|
||||
}
|
||||
|
||||
return {
|
||||
openid: response.data.openid,
|
||||
sessionKey: response.data.session_key,
|
||||
unionid: response.data.unionid
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('code2Session 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
*/
|
||||
generateNonceStr(length = 32) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成订单号
|
||||
*/
|
||||
generateOrderNo(prefix = 'WD') {
|
||||
const date = new Date()
|
||||
const dateStr = date.getFullYear().toString() +
|
||||
String(date.getMonth() + 1).padStart(2, '0') +
|
||||
String(date.getDate()).padStart(2, '0')
|
||||
const randomStr = Math.random().toString(36).substr(2, 6).toUpperCase()
|
||||
return `${prefix}${dateStr}${randomStr}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
*/
|
||||
generateSign(method, url, timestamp, nonceStr, body = '') {
|
||||
const signStr = `${method}\n${url}\n${timestamp}\n${nonceStr}\n${body}\n`
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(signStr)
|
||||
sign.end()
|
||||
return sign.sign(this.privateKey, 'base64')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支付订单(小程序支付 - 真实流程)
|
||||
*/
|
||||
async createPaymentOrder(orderData) {
|
||||
const { openid, orderNo, body, amount } = orderData
|
||||
|
||||
const nonceStr = this.generateNonceStr()
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
|
||||
// 1. 统一下单 (JSAPI)
|
||||
const url = '/v3/pay/transactions/jsapi'
|
||||
const fullUrl = `https://api.mch.weixin.qq.com${url}`
|
||||
|
||||
const orderParams = {
|
||||
appid: this.appId,
|
||||
mchid: this.mchId,
|
||||
description: body,
|
||||
out_trade_no: orderNo,
|
||||
notify_url: this.notifyUrl,
|
||||
amount: {
|
||||
total: Math.round(amount * 100), // 转换为分
|
||||
currency: 'CNY'
|
||||
},
|
||||
payer: {
|
||||
openid: openid
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 注意:实际生产环境需要处理网络请求和签名
|
||||
// const response = await axios.post(fullUrl, orderParams, { ... })
|
||||
// const prepayId = response.data.prepay_id
|
||||
|
||||
// 模拟获取 prepay_id
|
||||
const prepayId = `wx_mock_${Date.now()}`
|
||||
|
||||
// 2. 生成前端调起支付的签名
|
||||
const paySignStr = `${this.appId}\n${timestamp}\n${nonceStr}\nprepay_id=${prepayId}\n`
|
||||
const paySign = crypto.createSign('RSA-SHA256')
|
||||
paySign.update(paySignStr)
|
||||
paySign.end()
|
||||
const signature = paySign.sign(this.privateKey, 'base64')
|
||||
|
||||
return {
|
||||
appId: this.appId,
|
||||
timeStamp: timestamp,
|
||||
nonceStr,
|
||||
package: `prepay_id=${prepayId}`,
|
||||
signType: 'RSA',
|
||||
paySign: signature,
|
||||
orderNo
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('微信支付下单失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请退款
|
||||
*/
|
||||
async refundOrder(transactionId, outRefundNo, totalAmount, refundAmount, reason = '用户申请退款') {
|
||||
const url = '/v3/refund/domestic/refunds'
|
||||
const fullUrl = `https://api.mch.weixin.qq.com${url}`
|
||||
|
||||
const nonceStr = this.generateNonceStr()
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
|
||||
const refundParams = {
|
||||
transaction_id: transactionId,
|
||||
out_refund_no: outRefundNo,
|
||||
reason: reason,
|
||||
notify_url: this.notifyUrl.replace('/callback', '/refund-callback'), // 假设退款回调地址
|
||||
amount: {
|
||||
refund: Math.round(refundAmount * 100),
|
||||
total: Math.round(totalAmount * 100),
|
||||
currency: 'CNY'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 注意:实际生产环境需要处理网络请求和签名
|
||||
// const sign = this.generateSign('POST', url, timestamp, nonceStr, JSON.stringify(refundParams))
|
||||
// const response = await axios.post(fullUrl, refundParams, { ... })
|
||||
|
||||
console.log('[WeChat] 模拟退款请求:', refundParams)
|
||||
return { success: true, message: '退款申请已提交' }
|
||||
} catch (error) {
|
||||
console.error('微信支付退款失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付回调签名
|
||||
*/
|
||||
verifyCallbackSignature(data, sign) {
|
||||
// 实际应使用微信支付 V3 的签名验证方法
|
||||
// 这里仅作示例
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
const weChatService = new WeChatService()
|
||||
|
||||
module.exports = weChatService
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 认证 API 集成测试
|
||||
*/
|
||||
|
||||
const request = require('supertest')
|
||||
const mongoose = require('mongoose')
|
||||
const app = require('../../src/index')
|
||||
const User = require('../../src/models/User')
|
||||
const Admin = require('../../src/models/Admin')
|
||||
|
||||
describe('Auth API', () => {
|
||||
beforeAll(async () => {
|
||||
// 连接测试数据库
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// 清理并关闭连接
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// 每个测试前清空集合
|
||||
await User.deleteMany({})
|
||||
await Admin.deleteMany({})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/login', () => {
|
||||
test('新用户应该成功注册', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
openid: 'new_user_openid',
|
||||
userInfo: {
|
||||
nickName: '新用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.isNewUser).toBe(true)
|
||||
expect(response.body.data.openid).toBe('new_user_openid')
|
||||
expect(response.body.data.token).toBeDefined()
|
||||
})
|
||||
|
||||
test('老用户应该成功登录', async () => {
|
||||
// 先创建一个用户
|
||||
await User.create({
|
||||
openid: 'existing_user_openid',
|
||||
nickName: '老用户',
|
||||
totalScore: 100
|
||||
})
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
openid: 'existing_user_openid',
|
||||
userInfo: {
|
||||
nickName: '老用户新名字'
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.isNewUser).toBe(false)
|
||||
expect(response.body.data.openid).toBe('existing_user_openid')
|
||||
})
|
||||
|
||||
test('缺少 openid 应该返回错误', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({
|
||||
userInfo: {
|
||||
nickName: '测试用户'
|
||||
}
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/auth/admin/login', () => {
|
||||
beforeEach(async () => {
|
||||
// 创建测试管理员
|
||||
const admin = new Admin({
|
||||
username: 'testadmin',
|
||||
password: 'password123',
|
||||
role: 'super_admin',
|
||||
status: 'active'
|
||||
})
|
||||
await admin.save()
|
||||
})
|
||||
|
||||
test('管理员应该成功登录', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'testadmin',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.token).toBeDefined()
|
||||
expect(response.body.data.username).toBe('testadmin')
|
||||
expect(response.body.data.role).toBe('super_admin')
|
||||
})
|
||||
|
||||
test('错误的密码应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'testadmin',
|
||||
password: 'wrongpassword'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'nonexistent',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('禁用的账户应该返回403', async () => {
|
||||
// 创建禁用的管理员
|
||||
const disabledAdmin = new Admin({
|
||||
username: 'disabled',
|
||||
password: 'password123',
|
||||
role: 'viewer',
|
||||
status: 'inactive'
|
||||
})
|
||||
await disabledAdmin.save()
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({
|
||||
username: 'disabled',
|
||||
password: 'password123'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let userToken
|
||||
let testUser
|
||||
|
||||
beforeEach(async () => {
|
||||
// 创建测试用户并获取token
|
||||
testUser = await User.create({
|
||||
openid: 'test_user_openid',
|
||||
nickName: '测试用户',
|
||||
totalScore: 500
|
||||
})
|
||||
|
||||
// 登录获取token
|
||||
const loginResponse = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ openid: 'test_user_openid' })
|
||||
|
||||
userToken = loginResponse.body.data.token
|
||||
})
|
||||
|
||||
test('应该返回当前用户信息', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${userToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.openid).toBe('test_user_openid')
|
||||
expect(response.body.data.nickName).toBe('测试用户')
|
||||
})
|
||||
|
||||
test('缺少 token 应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回401', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.body.success).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 用户 API 集成测试
|
||||
*/
|
||||
|
||||
const request = require('supertest')
|
||||
const mongoose = require('mongoose')
|
||||
const app = require('../../src/index')
|
||||
const User = require('../../src/models/User')
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
describe('User API', () => {
|
||||
let testUser
|
||||
let authToken
|
||||
|
||||
beforeAll(async () => {
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(mongoURI)
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await User.deleteMany({})
|
||||
|
||||
// 创建测试用户
|
||||
testUser = await User.create({
|
||||
openid: 'test_user_openid',
|
||||
nickName: '测试用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100, completed: true },
|
||||
dim2: { bestArea: 200, completed: false }
|
||||
},
|
||||
totalScore: 300,
|
||||
unlockedDims: [1, 2]
|
||||
})
|
||||
|
||||
// 生成认证 Token
|
||||
authToken = jwt.sign(
|
||||
{ userId: testUser._id.toString(), openid: testUser.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h' }
|
||||
)
|
||||
})
|
||||
|
||||
describe('GET /api/user/progress', () => {
|
||||
test('应该返回用户探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data.exploreData).toBeDefined()
|
||||
expect(response.body.data.exploreData.dim1.bestScore).toBe(100)
|
||||
expect(response.body.data.exploreData.dim2.bestArea).toBe(200)
|
||||
})
|
||||
|
||||
test('不存在的用户应该返回404', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/progress')
|
||||
.set('Authorization', 'Bearer invalid_token')
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/progress', () => {
|
||||
test('应该成功保存探索进度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 1,
|
||||
score: 150,
|
||||
collectedEggs: 5
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
// 验证数据库更新
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.exploreData.dim1.bestScore).toBe(150)
|
||||
expect(updatedUser.exploreData.dim1.collectedEggs).toBe(5)
|
||||
})
|
||||
|
||||
test('新用户应该自动解锁维度', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/user/progress')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
dim: 3,
|
||||
score: 100
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.unlockedDims).toContain(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/user/leaderboard', () => {
|
||||
beforeEach(async () => {
|
||||
// 创建多个测试用户
|
||||
await User.create([
|
||||
{ openid: 'user1', nickName: '用户1', totalScore: 500, dim1Score: 200 },
|
||||
{ openid: 'user2', nickName: '用户2', totalScore: 300, dim1Score: 150 },
|
||||
{ openid: 'user3', nickName: '用户3', totalScore: 400, dim1Score: 100 }
|
||||
])
|
||||
})
|
||||
|
||||
test('应该返回总分排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
expect(response.body.data).toHaveLength(4) // 包含testUser
|
||||
expect(response.body.data[0].totalScore).toBeGreaterThanOrEqual(
|
||||
response.body.data[1].totalScore
|
||||
)
|
||||
})
|
||||
|
||||
test('应该返回维度排行榜', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ dim: 1, limit: 10 })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.data[0].dim1Score).toBe(200)
|
||||
})
|
||||
|
||||
test('应该限制返回数量', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/user/leaderboard')
|
||||
.query({ limit: 2 })
|
||||
|
||||
expect(response.body.data).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/user/info', () => {
|
||||
test('应该成功更新用户信息', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/user/info')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({
|
||||
nickName: '新名字',
|
||||
avatarUrl: 'https://example.com/new-avatar.jpg'
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.nickName).toBe('新名字')
|
||||
expect(updatedUser.avatarUrl).toBe('https://example.com/new-avatar.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/user/collect-knowledge', () => {
|
||||
test('应该成功收藏知识', async () => {
|
||||
const knowledgeId = new mongoose.Types.ObjectId()
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.body.success).toBe(true)
|
||||
|
||||
const updatedUser = await User.findOne({ openid: 'test_user_openid' })
|
||||
expect(updatedUser.collectedKnowledge).toContainEqual(knowledgeId)
|
||||
})
|
||||
|
||||
test('重复收藏应该返回已存在', async () => {
|
||||
const knowledgeId = new mongoose.Types.ObjectId()
|
||||
|
||||
// 第一次收藏
|
||||
await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
// 第二次收藏
|
||||
const response = await request(app)
|
||||
.post('/api/user/collect-knowledge')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ knowledgeId: knowledgeId.toString() })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Jest 测试设置
|
||||
*/
|
||||
|
||||
// 增加测试超时时间
|
||||
jest.setTimeout(30000)
|
||||
|
||||
// 全局清理
|
||||
afterAll(async () => {
|
||||
// 关闭所有数据库连接
|
||||
const mongoose = require('mongoose')
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.connection.close()
|
||||
}
|
||||
})
|
||||
|
||||
// 抑制控制台日志(测试时)
|
||||
if (process.env.SUPPRESS_LOGS) {
|
||||
global.console = {
|
||||
...console,
|
||||
log: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn()
|
||||
}
|
||||
}
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 用户模型单元测试
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose')
|
||||
const User = require('../../src/models/User')
|
||||
|
||||
describe('User Model', () => {
|
||||
beforeAll(async () => {
|
||||
// 连接测试数据库
|
||||
const mongoURI = process.env.MONGODB_URI || 'mongodb://localhost:27017/wdkj'
|
||||
await mongoose.connect(mongoURI)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// 清理并关闭连接
|
||||
await mongoose.connection.dropDatabase()
|
||||
await mongoose.connection.close()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// 每个测试前清空users集合
|
||||
await User.deleteMany({})
|
||||
})
|
||||
|
||||
describe('用户创建', () => {
|
||||
test('应该成功创建新用户', async () => {
|
||||
const userData = {
|
||||
openid: 'test_openid_123',
|
||||
nickName: '测试用户',
|
||||
avatarUrl: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
|
||||
const user = new User(userData)
|
||||
const savedUser = await user.save()
|
||||
|
||||
expect(savedUser._id).toBeDefined()
|
||||
expect(savedUser.openid).toBe(userData.openid)
|
||||
expect(savedUser.nickName).toBe(userData.nickName)
|
||||
expect(savedUser.avatarUrl).toBe(userData.avatarUrl)
|
||||
expect(savedUser.unlockedDims).toContain(1)
|
||||
expect(savedUser.totalScore).toBe(0)
|
||||
})
|
||||
|
||||
test('openid 是必需的', async () => {
|
||||
const user = new User({
|
||||
nickName: '测试用户'
|
||||
})
|
||||
|
||||
await expect(user.save()).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('openid 必须唯一', async () => {
|
||||
const userData = {
|
||||
openid: 'unique_openid',
|
||||
nickName: '用户1'
|
||||
}
|
||||
|
||||
await new User(userData).save()
|
||||
|
||||
const duplicateUser = new User({
|
||||
openid: 'unique_openid',
|
||||
nickName: '用户2'
|
||||
})
|
||||
|
||||
await expect(duplicateUser.save()).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('默认昵称应该是"星辰旅行者"', async () => {
|
||||
const user = new User({ openid: 'test_openid' })
|
||||
await user.save()
|
||||
|
||||
expect(user.nickName).toBe('星辰旅行者')
|
||||
})
|
||||
})
|
||||
|
||||
describe('探索数据', () => {
|
||||
test('应该正确计算总分', async () => {
|
||||
const user = new User({
|
||||
openid: 'test_openid',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100 },
|
||||
dim2: { bestArea: 200 },
|
||||
dim3: { bestScore: 150 }
|
||||
}
|
||||
})
|
||||
|
||||
await user.save()
|
||||
|
||||
expect(user.totalScore).toBe(450)
|
||||
expect(user.dim1Score).toBe(100)
|
||||
expect(user.dim2Score).toBe(200)
|
||||
expect(user.dim3Score).toBe(150)
|
||||
})
|
||||
|
||||
test('未解锁维度分数应为0', async () => {
|
||||
const user = new User({
|
||||
openid: 'test_openid',
|
||||
exploreData: {
|
||||
dim1: { bestScore: 100 }
|
||||
}
|
||||
})
|
||||
|
||||
await user.save()
|
||||
|
||||
expect(user.dim2Score).toBe(0)
|
||||
expect(user.dim3Score).toBe(0)
|
||||
expect(user.dim4Score).toBe(0)
|
||||
expect(user.dim5Score).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('虚拟字段', () => {
|
||||
test('isNew 应该对新用户返回 true', async () => {
|
||||
const user = new User({ openid: 'test_openid' })
|
||||
await user.save()
|
||||
|
||||
expect(user.isNew).toBe(true)
|
||||
})
|
||||
|
||||
test('isNew 应该对老用户返回 false', async () => {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 2)
|
||||
|
||||
const user = new User({
|
||||
openid: 'test_openid',
|
||||
createdAt: yesterday
|
||||
})
|
||||
await user.save()
|
||||
|
||||
expect(user.isNew).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('静态方法', () => {
|
||||
test('getLeaderboard 应该返回排行榜', async () => {
|
||||
// 创建测试用户
|
||||
await User.create([
|
||||
{ openid: 'user1', totalScore: 300, dim1Score: 100 },
|
||||
{ openid: 'user2', totalScore: 500, dim1Score: 200 },
|
||||
{ openid: 'user3', totalScore: 400, dim1Score: 150 }
|
||||
])
|
||||
|
||||
const leaderboard = await User.getLeaderboard(null, 10)
|
||||
|
||||
expect(leaderboard).toHaveLength(3)
|
||||
expect(leaderboard[0].totalScore).toBe(500)
|
||||
expect(leaderboard[1].totalScore).toBe(400)
|
||||
expect(leaderboard[2].totalScore).toBe(300)
|
||||
})
|
||||
|
||||
test('getLeaderboard 应该支持按维度排序', async () => {
|
||||
await User.create([
|
||||
{ openid: 'user1', dim1Score: 100, totalScore: 100 },
|
||||
{ openid: 'user2', dim1Score: 200, totalScore: 200 },
|
||||
{ openid: 'user3', dim1Score: 150, totalScore: 150 }
|
||||
])
|
||||
|
||||
const leaderboard = await User.getLeaderboard(1, 10)
|
||||
|
||||
expect(leaderboard[0].dim1Score).toBe(200)
|
||||
expect(leaderboard[1].dim1Score).toBe(150)
|
||||
expect(leaderboard[2].dim1Score).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user