Compare commits
14 Commits
master
..
e73149f91d
| Author | SHA1 | Date | |
|---|---|---|---|
| e73149f91d | |||
| e2dae5942d | |||
| 58b74257c7 | |||
| b31835a74d | |||
| c3b309413e | |||
| bfbfdccdea | |||
| 0ff5a02155 | |||
| f9a4d50b09 | |||
| 21f9824a24 | |||
| 9f2f9ba7f6 | |||
| 67e7c251a6 | |||
| 9c52975b5a | |||
| 519f4c2def | |||
| 8bab5d67b2 |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node -e \"const s = require\\('sharp'\\); console.log\\('sharp version:', s.versions ? JSON.stringify\\(s.versions\\) : 'ok'\\); console.log\\('sharp loaded successfully'\\);\")",
|
||||
"Bash(node -e \"const fs=require\\('fs'\\);const d=fs.readFileSync\\('/dev/stdin','utf8'\\);const j=JSON.parse\\(d\\);const s=j.packages['node_modules/sharp'];console.log\\(s?'sharp in lockfile version:'+\\(s.version||'unknown'\\):'sharp NOT in package-lock'\\);\")",
|
||||
"Bash(node *)",
|
||||
"Bash(ssh *)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\middleware\\\\adminAuth.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/middleware/adminAuth.js)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\routes\\\\admin.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/admin.js)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\public\\\\admin\\\\index.html\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/public/admin/index.html)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\app.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/app.js)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\docker-compose.yml\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/docker-compose.yml)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\Dockerfile\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/Dockerfile)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/models/User.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/models/User.js)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/routes/auth.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/auth.js)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/routes/admin.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/admin.js)",
|
||||
"Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no public/admin/index.html root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/public/admin/index.html)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(GIT_SSH_COMMAND='ssh -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no' git push *)",
|
||||
"Bash(scp *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
README.md
|
||||
project.md
|
||||
*.md
|
||||
*.bat
|
||||
*.zip
|
||||
*.tar.gz
|
||||
scripts/
|
||||
OwnershipEquity/
|
||||
nginx-api.conf
|
||||
nul
|
||||
backend-deploy.tar.gz
|
||||
new-backend-deploy.zip
|
||||
@@ -0,0 +1,27 @@
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# 微信小程序配置
|
||||
WECHAT_APPID=your_app_id_here
|
||||
WECHAT_APPSECRET=your_app_secret_here
|
||||
|
||||
# MongoDB数据库配置
|
||||
MONGODB_URI=mongodb://localhost:27017/quanyixiaozhushou
|
||||
|
||||
# JWT配置
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=info
|
||||
|
||||
# 百度OCR配置
|
||||
BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT
|
||||
BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4
|
||||
|
||||
# 服务器地址
|
||||
SERVER_URL=https://api-miniapp.dxz99wyr.cn
|
||||
|
||||
# 数据导出加密密钥(建议设置一个复杂的密钥)
|
||||
EXPORT_ENCRYPT_KEY=your_export_encrypt_key_here
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
|
||||
# MongoDB local data
|
||||
mongodb/
|
||||
|
||||
# Deploy archives
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# Claude conversation logs
|
||||
*-claude.txt
|
||||
|
||||
# Junk files
|
||||
nul
|
||||
VIP:*
|
||||
@@ -1,32 +0,0 @@
|
||||
# 修复 /api/upload/image 404 问题
|
||||
|
||||
## 问题原因
|
||||
|
||||
后端 `upload.js` 路由已写入代码并本地 commit,但代码**未推送到远程**,服务器部署的是旧代码,没有 `/api/upload/image` 路由。
|
||||
|
||||
当前后端状态:
|
||||
- 本地 commit `bfbfdcc` 包含 upload 路由和 note 字段改动
|
||||
- `origin/main` 落后 1 个 commit
|
||||
- 分支名:`main`
|
||||
|
||||
## 修复步骤
|
||||
|
||||
### 1. 推送 backend 子模块代码
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
git -c http.sslVerify=false push origin main
|
||||
```
|
||||
|
||||
### 2. 更新主仓库子模块指针到 miniapp-api
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
git add backend
|
||||
git commit -m "chore: 更新backend子模块(修复upload路由404)"
|
||||
git -c http.sslVerify=false push origin master
|
||||
```
|
||||
|
||||
### 3. 服务器重新部署
|
||||
|
||||
服务器拉取最新 backend 代码后,`POST /api/upload/image` 即可正常响应。
|
||||
@@ -1,24 +0,0 @@
|
||||
# 项目规则
|
||||
|
||||
## Git 仓库信息
|
||||
|
||||
| 项目 | 远程地址 | 分支 |
|
||||
|------|---------|------|
|
||||
| 主仓库 (miniapp-api) | `https://git.dxz99wyr.cn/Superuser/miniapp-api.git` | master |
|
||||
| miniapp 子模块 | `https://git.dxz99wyr.cn/Superuser/miniapp-web.git` | master |
|
||||
| backend 子模块 | backend 目录 | - |
|
||||
|
||||
## Git 凭据
|
||||
|
||||
- 用户名: `Superuser`
|
||||
- 密码: `Admin@123`
|
||||
- 凭据已存入 git credential store,无需手动输入
|
||||
|
||||
## Git 推送
|
||||
|
||||
- **只推送 miniapp-api 主仓库**,不要推送子模块仓库(miniapp-web、backend)
|
||||
- 推送时需要关闭 SSL 验证(服务器 SSL 证书问题):
|
||||
|
||||
```bash
|
||||
git -c http.sslVerify=false push origin master
|
||||
```
|
||||
-560
@@ -1,560 +0,0 @@
|
||||
# 权益小助手 - CI/CD 流程规范
|
||||
|
||||
## 项目概述
|
||||
|
||||
- **项目名称**: 权益小助手 (QuanYiXiaoZhuShou)
|
||||
- **项目类型**: 微信小程序 + Node.js 后端
|
||||
- **当前版本**: v0.1.0
|
||||
- **Git 仓库**: https://git.dxz99wyr.cn/Superuser/miniapp-api
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
QuanYiXiaoZhuShou/
|
||||
├── backend/ # Node.js 后端服务
|
||||
│ ├── src/ # 源代码
|
||||
│ ├── Dockerfile # Docker 构建文件
|
||||
│ ├── docker-compose.yml # Docker Compose 配置
|
||||
│ ├── nginx-api.conf # Nginx 反向代理配置
|
||||
│ └── package.json # 依赖管理
|
||||
├── miniapp/ # 微信小程序前端
|
||||
│ ├── pages/ # 页面代码
|
||||
│ ├── utils/ # 工具函数
|
||||
│ └── app.js # 小程序入口
|
||||
└── test/ # 测试相关
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端 (Backend)
|
||||
- **Runtime**: Node.js >= 18.0.0
|
||||
- **Framework**: Express.js 4.18.2
|
||||
- **Database**: MongoDB 7.0
|
||||
- **Process Manager**: PM2
|
||||
- **Container**: Docker + Docker Compose
|
||||
- **Web Server**: Nginx (反向代理)
|
||||
|
||||
### 前端 (Miniapp)
|
||||
- **Platform**: 微信小程序
|
||||
- **Language**: JavaScript
|
||||
- **Build Tool**: 微信开发者工具
|
||||
|
||||
### 服务器环境
|
||||
- **OS**: Ubuntu/Debian (推测)
|
||||
- **Server IP**: 8.136.137.59
|
||||
- **Domain**: api-miniapp.dxz99wyr.cn
|
||||
- **SSL**: 已配置
|
||||
- **Deployment Path**: /var/www/quanyixiaozhushou-app
|
||||
|
||||
## 当前部署状态
|
||||
|
||||
### 服务器部署方式
|
||||
当前使用 **Docker** 容器部署,包含两个容器:
|
||||
|
||||
| 容器名称 | 服务 | 端口 | 状态 |
|
||||
|---------|------|------|------|
|
||||
| `quanyixiaozhushou-app` | Node.js 后端 | 3000 | 运行中 |
|
||||
| `quanyixiaozhushou-mongo` | MongoDB 7.0 | 27018 | 运行中 |
|
||||
|
||||
```bash
|
||||
# 查看容器状态
|
||||
docker ps
|
||||
|
||||
# 查看后端日志
|
||||
docker logs quanyixiaozhushou-app
|
||||
|
||||
# 查看 MongoDB 日志
|
||||
docker logs quanyixiaozhushou-mongo
|
||||
```
|
||||
|
||||
### 环境变量 (生产环境)
|
||||
```env
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
WECHAT_APPID=wxa83262674846ca1a
|
||||
WECHAT_APPSECRET=c40e9d356438f92d10091a115ee50172
|
||||
MONGODB_URI=mongodb://localhost:27017/quanyixiaozhushou
|
||||
JWT_SECRET=your_jwt_secret_key_here_change_in_production
|
||||
JWT_EXPIRES_IN=7d
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
## CI/CD 流程设计
|
||||
|
||||
### 1. Git 工作流
|
||||
|
||||
```
|
||||
[Developer] → feature branch → Pull Request → [Code Review] → merge to master → [CI/CD Pipeline] → Deploy
|
||||
```
|
||||
|
||||
### 2. 分支策略
|
||||
|
||||
| 分支 | 用途 | 保护规则 |
|
||||
|------|------|----------|
|
||||
| master | 生产环境分支 | 禁止直接推送,需 PR |
|
||||
| develop | 开发环境分支 | 需 PR |
|
||||
| feature/* | 功能开发 | 自由推送 |
|
||||
| hotfix/* | 紧急修复 | 需 PR 到 master |
|
||||
|
||||
### 3. CI/CD Pipeline 流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Git Push/PR] --> B[Checkout Code]
|
||||
B --> C[Install Dependencies]
|
||||
C --> D[Run Tests]
|
||||
D --> E[Lint Check]
|
||||
E --> F[Build Docker Image]
|
||||
F --> G[Push to Registry]
|
||||
G --> H[Deploy to Server]
|
||||
H --> I[Health Check]
|
||||
I --> J[Notify]
|
||||
```
|
||||
|
||||
## 详细 CI/CD 配置
|
||||
|
||||
### Stage 1: 代码检出 (Checkout)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci-cd.yml
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, develop ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: https://git.dxz99wyr.cn/Superuser/miniapp-api
|
||||
token: ${{ secrets.GIT_ACCESS_TOKEN }}
|
||||
```
|
||||
|
||||
### Stage 2: 依赖安装与测试
|
||||
|
||||
```yaml
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm ci
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: npm test
|
||||
continue-on-error: true # 如果没有测试,不阻塞流程
|
||||
|
||||
- name: Run backend lint
|
||||
working-directory: ./backend
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
```
|
||||
|
||||
### Stage 3: Docker 构建
|
||||
|
||||
```yaml
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io # 或你的私有仓库
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}/backend:${{ github.sha }}
|
||||
ghcr.io/${{ github.repository }}/backend:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
### Stage 4: 部署到服务器
|
||||
|
||||
```yaml
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: 8.136.137.59
|
||||
username: root
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
# 拉取最新镜像
|
||||
docker pull ghcr.io/${{ github.repository }}/backend:latest
|
||||
|
||||
# 停止旧容器
|
||||
docker stop quanyi-backend || true
|
||||
docker rm quanyi-backend || true
|
||||
|
||||
# 启动新容器
|
||||
docker run -d \
|
||||
--name quanyi-backend \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e WECHAT_APPID=${{ secrets.WECHAT_APPID }} \
|
||||
-e WECHAT_APPSECRET=${{ secrets.WECHAT_APPSECRET }} \
|
||||
-e MONGODB_URI=${{ secrets.MONGODB_URI }} \
|
||||
-e JWT_SECRET=${{ secrets.JWT_SECRET }} \
|
||||
-v /var/www/quanyixiaozhushou-app/public/uploads:/app/public/uploads \
|
||||
ghcr.io/${{ github.repository }}/backend:latest
|
||||
|
||||
# 健康检查
|
||||
sleep 10
|
||||
curl -f http://localhost:3000/health || exit 1
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### GitHub Secrets 需要配置
|
||||
|
||||
| Secret Name | Description | Example |
|
||||
|-------------|-------------|---------|
|
||||
| `GIT_ACCESS_TOKEN` | Git 仓库访问令牌 | 用于拉取 `https://git.dxz99wyr.cn/Superuser/miniapp-api` |
|
||||
| `SSH_PRIVATE_KEY` | 服务器 SSH 私钥 | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
|
||||
| `WECHAT_APPID` | 微信小程序 AppID | `wxa83262674846ca1a` |
|
||||
| `WECHAT_APPSECRET` | 微信小程序 AppSecret | `c40e9d356438f92d10091a115ee50172` |
|
||||
| `MONGODB_URI` | MongoDB 连接字符串 | `mongodb://localhost:27017/quanyixiaozhushou` |
|
||||
| `JWT_SECRET` | JWT 密钥 | `your_jwt_secret_key` |
|
||||
|
||||
## 部署脚本 (服务器端)
|
||||
|
||||
### 1. 初始化脚本 (首次部署)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# deploy-init.sh
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_DIR="/var/www/quanyixiaozhushou-app"
|
||||
BACKUP_DIR="/var/backups/quanyixiaozhushou"
|
||||
|
||||
# 创建目录
|
||||
mkdir -p $PROJECT_DIR
|
||||
mkdir -p $BACKUP_DIR
|
||||
mkdir -p $PROJECT_DIR/public/uploads
|
||||
mkdir -p $PROJECT_DIR/public/avatars
|
||||
|
||||
# 安装 Docker (如果未安装)
|
||||
if ! command -v docker &> /dev/null; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
fi
|
||||
|
||||
# 安装 Docker Compose
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
curl -L "https://github.com/docker/compose/releases/download/v2.23.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
chmod +x /usr/local/bin/docker-compose
|
||||
fi
|
||||
|
||||
echo "初始化完成"
|
||||
```
|
||||
|
||||
### 2. 部署脚本 (日常更新)
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# deploy.sh
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE_TAG=${1:-latest}
|
||||
PROJECT_DIR="/var/www/quanyixiaozhushou-app"
|
||||
BACKUP_DIR="/var/backups/quanyixiaozhushou"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "=== 开始部署 ==="
|
||||
echo "镜像标签: $IMAGE_TAG"
|
||||
|
||||
# 1. 备份当前数据
|
||||
echo "[1/5] 备份当前数据..."
|
||||
mkdir -p $BACKUP_DIR/$TIMESTAMP
|
||||
cp -r $PROJECT_DIR/public/uploads $BACKUP_DIR/$TIMESTAMP/ 2>/dev/null || true
|
||||
mongodump --db quanyixiaozhushou --out $BACKUP_DIR/$TIMESTAMP/ 2>/dev/null || true
|
||||
|
||||
# 2. 拉取最新镜像
|
||||
echo "[2/5] 拉取最新镜像..."
|
||||
docker pull ghcr.io/your-repo/quanyixiaozhushou-backend:$IMAGE_TAG
|
||||
|
||||
# 3. 停止旧容器
|
||||
echo "[3/5] 停止旧容器..."
|
||||
docker stop quanyi-backend 2>/dev/null || true
|
||||
docker rm quanyi-backend 2>/dev/null || true
|
||||
|
||||
# 4. 启动新容器
|
||||
echo "[4/5] 启动新容器..."
|
||||
docker run -d \
|
||||
--name quanyi-backend \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e WECHAT_APPID=$WECHAT_APPID \
|
||||
-e WECHAT_APPSECRET=$WECHAT_APPSECRET \
|
||||
-e MONGODB_URI=$MONGODB_URI \
|
||||
-e JWT_SECRET=$JWT_SECRET \
|
||||
-v $PROJECT_DIR/public/uploads:/app/public/uploads \
|
||||
-v $PROJECT_DIR/public/avatars:/app/public/avatars \
|
||||
ghcr.io/your-repo/quanyixiaozhushou-backend:$IMAGE_TAG
|
||||
|
||||
# 5. 健康检查
|
||||
echo "[5/5] 健康检查..."
|
||||
sleep 5
|
||||
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
if curl -f -s http://localhost:3000/health > /dev/null; then
|
||||
echo "✅ 服务健康检查通过"
|
||||
break
|
||||
fi
|
||||
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "等待服务启动... ($RETRY_COUNT/$MAX_RETRIES)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||
echo "❌ 健康检查失败,执行回滚..."
|
||||
# 回滚逻辑
|
||||
docker stop quanyi-backend
|
||||
docker rm quanyi-backend
|
||||
# 启动上一个版本
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清理旧镜像
|
||||
docker image prune -f
|
||||
|
||||
echo "=== 部署完成 ==="
|
||||
echo "备份位置: $BACKUP_DIR/$TIMESTAMP"
|
||||
```
|
||||
|
||||
## 健康检查端点
|
||||
|
||||
确保后端有以下健康检查端点:
|
||||
|
||||
```javascript
|
||||
// src/app.js
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || '1.0.0'
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 微信小程序 CI/CD
|
||||
|
||||
### 自动上传脚本
|
||||
|
||||
```yaml
|
||||
# .github/workflows/miniapp-deploy.yml
|
||||
name: Miniapp Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'miniapp/**'
|
||||
|
||||
jobs:
|
||||
deploy-miniapp:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install miniprogram-ci
|
||||
run: npm install -g miniprogram-ci
|
||||
|
||||
- name: Upload to WeChat
|
||||
working-directory: ./miniapp
|
||||
run: |
|
||||
miniprogram-ci upload \
|
||||
--pp ./ \
|
||||
--pkp ${{ secrets.WECHAT_PRIVATE_KEY }} \
|
||||
--appid ${{ secrets.WECHAT_APPID }} \
|
||||
--uv ${{ github.run_number }} \
|
||||
--enable-es6 true
|
||||
```
|
||||
|
||||
## 监控与告警
|
||||
|
||||
### 1. 日志监控
|
||||
|
||||
```bash
|
||||
# 查看实时日志
|
||||
docker logs -f quanyi-backend
|
||||
|
||||
# 查看错误日志
|
||||
docker logs quanyi-backend 2>&1 | grep ERROR
|
||||
```
|
||||
|
||||
### 进程监控
|
||||
|
||||
```bash
|
||||
# Docker 容器监控
|
||||
docker stats quanyixiaozhushou-app
|
||||
|
||||
# 进入容器调试
|
||||
docker exec -it quanyixiaozhushou-app sh
|
||||
```
|
||||
|
||||
### 3. 告警规则
|
||||
|
||||
- 服务 5xx 错误率 > 1%
|
||||
- 响应时间 > 2s
|
||||
- 内存使用 > 80%
|
||||
- CPU 使用 > 80%
|
||||
- 磁盘使用 > 85%
|
||||
|
||||
## 回滚策略
|
||||
|
||||
### 自动回滚条件
|
||||
|
||||
- 健康检查失败
|
||||
- 错误率突增
|
||||
- 部署后 5 分钟内服务不可用
|
||||
|
||||
### 回滚脚本
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# rollback.sh
|
||||
|
||||
PREVIOUS_IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}}" | grep quanyixiaozhushou-backend | head -2 | tail -1)
|
||||
|
||||
echo "回滚到上一个版本: $PREVIOUS_IMAGE"
|
||||
|
||||
docker stop quanyi-backend
|
||||
docker rm quanyi-backend
|
||||
docker run -d \
|
||||
--name quanyi-backend \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e NODE_ENV=production \
|
||||
-v /var/www/quanyixiaozhushou-app/public/uploads:/app/public/uploads \
|
||||
$PREVIOUS_IMAGE
|
||||
|
||||
echo "回滚完成"
|
||||
```
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
### MongoDB 迁移脚本
|
||||
|
||||
```javascript
|
||||
// migrations/001_add_subscription_type.js
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
async function migrate() {
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
|
||||
// 添加 type 字段到现有订阅记录
|
||||
await mongoose.connection.collection('usersubscriptions').updateMany(
|
||||
{ type: { $exists: false } },
|
||||
{ $set: { type: 'version-update' } }
|
||||
);
|
||||
|
||||
console.log('Migration completed');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
migrate().catch(console.error);
|
||||
```
|
||||
|
||||
## 安全检查清单
|
||||
|
||||
- [ ] 环境变量不提交到 Git
|
||||
- [ ] 使用 secrets 管理敏感信息
|
||||
- [ ] Docker 镜像使用非 root 用户运行
|
||||
- [ ] 启用 HTTPS
|
||||
- [ ] 配置防火墙规则
|
||||
- [ ] 定期更新依赖
|
||||
- [ ] 启用日志审计
|
||||
- [ ] 配置备份策略
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 常用命令
|
||||
|
||||
```bash
|
||||
# 查看容器状态
|
||||
docker ps
|
||||
|
||||
# 查看容器日志
|
||||
docker logs quanyi-backend
|
||||
|
||||
# 进入容器
|
||||
docker exec -it quanyi-backend sh
|
||||
|
||||
# 重启容器
|
||||
docker restart quanyi-backend
|
||||
|
||||
# 查看资源使用
|
||||
docker stats quanyi-backend
|
||||
```
|
||||
|
||||
### B. 故障排查
|
||||
|
||||
```bash
|
||||
# 服务无法启动
|
||||
docker logs quanyi-backend
|
||||
|
||||
# MongoDB 连接失败
|
||||
docker exec -it quanyi-backend node -e "require('mongoose').connect(process.env.MONGODB_URI).then(() => console.log('OK')).catch(e => console.error(e))"
|
||||
|
||||
# 端口占用
|
||||
netstat -tlnp | grep 3000
|
||||
```
|
||||
|
||||
### C. 联系方式与仓库信息
|
||||
|
||||
- **Git 仓库**: https://git.dxz99wyr.cn/Superuser/miniapp-api
|
||||
- **服务器**: 8.136.137.59
|
||||
- **域名**: api-miniapp.dxz99wyr.cn
|
||||
- **SSH 密钥**: D:\003_Project\小程序连接.pem
|
||||
|
||||
### D. Git 仓库配置命令
|
||||
|
||||
```bash
|
||||
# 添加远程仓库
|
||||
git remote add origin https://git.dxz99wyr.cn/Superuser/miniapp-api
|
||||
|
||||
# 推送代码
|
||||
git push -u origin master
|
||||
|
||||
# 拉取最新代码
|
||||
git pull origin master
|
||||
```
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
FROM node:24-slim
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
|
||||
RUN mkdir -p public/uploads/avatars public/avatars public/admin
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/app.js"]
|
||||
@@ -0,0 +1,7 @@
|
||||
优酷/芒果年卡(二选一),年卡价值258元
|
||||
网易云音乐年卡,年卡价值216元
|
||||
夸克网盘年卡,买年卡价值198元
|
||||
高德打车直达Lv6 会员
|
||||
飞猪直达F4会员
|
||||
淘票票,每月2张4元优惠券
|
||||
退货包运费
|
||||
@@ -1,4 +1,26 @@
|
||||
# 权益小助手
|
||||
# 权益小助手后端服务
|
||||
|
||||
个人权益管理小程序,支持淘宝88VIP、京东PLUS、支付宝等平台的权益记录与同步。
|
||||
test deploy
|
||||
## 项目简介
|
||||
|
||||
本项目是「权益小助手」的后端服务,为前端应用提供 API 接口支持。当前处于项目初始化阶段,尚未开始正式开发。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── project.md # 项目信息、里程碑及开发规范
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 项目已创建基础文档
|
||||
- 等待后续技术选型与框架搭建
|
||||
|
||||
## 技术栈(待定)
|
||||
|
||||
待需求分析完成后确定。
|
||||
|
||||
## 开发规范
|
||||
|
||||
详见 [project.md](./project.md)。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 小程序后台 — 部署配置参考
|
||||
|
||||
> 摘录自 `D:\003_Project\004_Git\REPOS.md`,仅保留公共部分 + miniapp-api 相关内容。
|
||||
|
||||
## 服务器
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| IP | `8.136.137.59` |
|
||||
| SSH 密钥 | `D:\003_Project\小程序连接.pem` |
|
||||
| SSH 连接 | `ssh -i "D:\003_Project\小程序连接.pem" root@8.136.137.59` |
|
||||
|
||||
## Gitea
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| 网址 | `https://git.dxz99wyr.cn` |
|
||||
| 用户名 | `Superuser` |
|
||||
| 密码 | `Admin@123` |
|
||||
| SSH Git 端口 | `2222` |
|
||||
| SSH Git 地址格式 | `ssh://git@8.136.137.59:2222/Superuser/<仓库名>.git` |
|
||||
| HTTP Git 地址格式 | `https://git.dxz99wyr.cn/Superuser/<仓库名>.git` |
|
||||
|
||||
## miniapp-api(小程序后台)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Git 地址 (SSH) | `ssh://git@8.136.137.59:2222/Superuser/miniapp-api.git` |
|
||||
| Git 地址 (HTTPS) | `https://git.dxz99wyr.cn/Superuser/miniapp-api.git` |
|
||||
| 本地路径 | `D:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend` |
|
||||
| 服务器路径 | `/opt/ALiYunManager/services/miniapp-api` |
|
||||
| Docker 服务名 | `miniapp-api` |
|
||||
| 容器名 | `miniapp-api` |
|
||||
| 部署方式 | `git pull` → `docker compose -f /opt/ALiYunManager/docker-compose.yml up -d --no-deps --force-recreate miniapp-api` |
|
||||
| Webhook Secret | `miniapp-api-deploy-secret` |
|
||||
|
||||
## 日常开发流程
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: 描述你的改动"
|
||||
git push
|
||||
# ↑ push 后自动触发 Webhook → 服务器拉代码 → 重启容器,无需手动部署
|
||||
```
|
||||
|
||||
## 服务器常用命令
|
||||
|
||||
```bash
|
||||
# 查看所有容器
|
||||
ssh -i "D:\003_Project\小程序连接.pem" root@8.136.137.59 "docker ps"
|
||||
|
||||
# 查看部署日志
|
||||
ssh -i "D:\003_Project\小程序连接.pem" root@8.136.137.59 "tail -f /home/Git/logs/deploy.log"
|
||||
|
||||
# 重启 miniapp-api
|
||||
ssh -i "D:\003_Project\小程序连接.pem" root@8.136.137.59 "cd /opt/ALiYunManager && docker compose up -d --no-deps --force-recreate miniapp-api"
|
||||
|
||||
# 重载 main-nginx
|
||||
ssh -i "D:\003_Project\小程序连接.pem" root@8.136.137.59 "docker exec main-nginx nginx -t && docker exec main-nginx nginx -s reload"
|
||||
```
|
||||
-1
Submodule backend deleted from e2dae5942d
@@ -0,0 +1,120 @@
|
||||
# MiniApp API Test 自动化部署配置
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `docker-compose.test.yml` | 测试环境 Docker Compose 配置 |
|
||||
| `deploy/webhook-server.js` | Webhook 服务器,接收 Git 推送并自动部署 |
|
||||
| `deploy/webhook.service` | systemd 服务配置,保持 webhook 常驻运行 |
|
||||
| `deploy/setup.sh` | 一键初始化脚本 |
|
||||
|
||||
## 云服务器部署步骤
|
||||
|
||||
### 1. 修改配置
|
||||
|
||||
编辑以下文件,替换为你的实际信息:
|
||||
|
||||
- **`deploy/setup.sh`**:
|
||||
```bash
|
||||
GIT_REPO="git@github.com:your-username/miniapp-api_test.git"
|
||||
```
|
||||
|
||||
- **`deploy/webhook.service`**:
|
||||
```ini
|
||||
Environment="WEBHOOK_SECRET=你的webhook密钥"
|
||||
```
|
||||
|
||||
- **`deploy/webhook-server.js`** (可选):
|
||||
```javascript
|
||||
const SECRET = process.env.WEBHOOK_SECRET || '你的webhook密钥';
|
||||
```
|
||||
|
||||
### 2. 上传代码到服务器
|
||||
|
||||
```bash
|
||||
# 方式1: 直接上传
|
||||
scp -r ./* root@your-server-ip:/opt/miniapp-api_test/
|
||||
|
||||
# 方式2: 先推送到 git,再在服务器克隆
|
||||
```
|
||||
|
||||
### 3. 运行初始化脚本
|
||||
|
||||
```bash
|
||||
ssh root@your-server-ip
|
||||
cd /opt/miniapp-api_test
|
||||
chmod +x deploy/setup.sh
|
||||
./deploy/setup.sh
|
||||
```
|
||||
|
||||
### 4. 配置 Git Webhook
|
||||
|
||||
在 Git 仓库设置中添加 Webhook:
|
||||
|
||||
- **Payload URL**: `http://your-server-ip:9001/webhook`
|
||||
- **Content type**: `application/json`
|
||||
- **Secret**: 你设置的 `WEBHOOK_SECRET`
|
||||
- **触发事件**: Push events (main 分支)
|
||||
|
||||
### 5. 开放防火墙端口
|
||||
|
||||
```bash
|
||||
# 开放 9001 端口(webhook)和 3001 端口(API)
|
||||
ufw allow 9001/tcp
|
||||
ufw allow 3001/tcp
|
||||
```
|
||||
|
||||
### 6. 配置 Nginx 反向代理 (推荐)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name miniapp-api-test.dxz99wyr.cn;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看 webhook 日志
|
||||
journalctl -u miniapp-api_test-webhook -f
|
||||
|
||||
# 查看容器状态
|
||||
docker-compose -f docker-compose.test.yml ps
|
||||
|
||||
# 查看容器日志
|
||||
docker logs -f miniapp-api_test
|
||||
|
||||
# 手动重启容器
|
||||
docker-compose -f docker-compose.test.yml restart
|
||||
|
||||
# 手动触发部署
|
||||
cd /opt/miniapp-api_test && docker-compose -f docker-compose.test.yml up -d --build
|
||||
```
|
||||
|
||||
## 升级正式版本
|
||||
|
||||
当测试版本稳定后,执行以下操作升级到正式版:
|
||||
|
||||
```bash
|
||||
# 1. 推送测试版本代码到正式仓库
|
||||
cd /opt/miniapp-api_test
|
||||
git push 正式仓库地址 main
|
||||
|
||||
# 2. 在正式环境重新构建
|
||||
# (正式环境的部署方式保持不变)
|
||||
```
|
||||
|
||||
或者由开发者手动推送:
|
||||
```bash
|
||||
git remote add production <正式仓库地址>
|
||||
git push production main
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo " MiniApp API Test 自动化部署环境配置"
|
||||
echo "=========================================="
|
||||
|
||||
DEPLOY_DIR="/opt/miniapp-api_test"
|
||||
GIT_REPO="ssh://git@8.136.137.59:2222/Superuser/miniapp-api_test.git"
|
||||
|
||||
echo ""
|
||||
echo "[1/6] 创建部署目录..."
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo ""
|
||||
echo "[2/6] 克隆 miniapp-api_test 仓库..."
|
||||
if [ ! -d ".git" ]; then
|
||||
git clone "$GIT_REPO" .
|
||||
else
|
||||
echo "仓库已存在, 跳过克隆"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[3/6] 创建必要的目录..."
|
||||
mkdir -p public/uploads public/avatars public/admin
|
||||
|
||||
echo ""
|
||||
echo "[4/6] 配置 systemd 服务..."
|
||||
cp deploy/webhook.service /etc/systemd/system/miniapp-api_test-webhook.service
|
||||
systemctl daemon-reload
|
||||
systemctl enable miniapp-api_test-webhook.service
|
||||
|
||||
echo ""
|
||||
echo "[5/6] 启动 webhook 服务..."
|
||||
systemctl start miniapp-api_test-webhook.service
|
||||
|
||||
echo ""
|
||||
echo "[6/6] 启动 Docker 容器..."
|
||||
docker-compose -f docker-compose.test.yml up -d --build
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 配置完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Webhook 接收地址: http://$(curl -s ifconfig.me):9001/webhook"
|
||||
echo "API 测试地址: https://miniapp-api-test.dxz99wyr.cn"
|
||||
echo ""
|
||||
echo "查看 webhook 日志: journalctl -u miniapp-api_test-webhook -f"
|
||||
echo "查看容器状态: docker-compose -f docker-compose.test.yml ps"
|
||||
echo ""
|
||||
@@ -0,0 +1,105 @@
|
||||
const http = require('http');
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PORT = 9001;
|
||||
const DEPLOY_DIR = '/opt/miniapp-api_test';
|
||||
const COMPOSE_FILE = 'docker-compose.test.yml';
|
||||
|
||||
const SECRET = process.env.WEBHOOK_SECRET || 'miniapp-api-deploy-secret';
|
||||
|
||||
function verifySignature(payload, signature) {
|
||||
const hmac = crypto.createHmac('sha256', SECRET);
|
||||
const digest = 'sha256=' + hmac.update(payload).digest('hex');
|
||||
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature));
|
||||
}
|
||||
|
||||
function runCommand(command, cwd) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[${new Date().toISOString()}] 执行命令: ${command}`);
|
||||
const child = exec(command, { cwd, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`[${new Date().toISOString()}] 命令执行失败:`, error.message);
|
||||
console.error('stderr:', stderr);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(`[${new Date().toISOString()}] 命令输出:\n${stdout}`);
|
||||
if (stderr) console.error(`stderr: ${stderr}`);
|
||||
resolve(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function deploy() {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`\n========== 开始部署 miniapp-api_test [${timestamp}] ==========`);
|
||||
|
||||
try {
|
||||
await runCommand('git fetch origin', DEPLOY_DIR);
|
||||
await runCommand('git reset --hard origin/main', DEPLOY_DIR);
|
||||
|
||||
await runCommand(`docker-compose -f ${COMPOSE_FILE} build --no-cache`, DEPLOY_DIR);
|
||||
await runCommand(`docker-compose -f ${COMPOSE_FILE} up -d`, DEPLOY_DIR);
|
||||
|
||||
await runCommand('docker image prune -f', DEPLOY_DIR);
|
||||
|
||||
console.log(`[${new Date().toISOString()}] 部署成功完成`);
|
||||
return { success: true, message: '部署成功' };
|
||||
} catch (error) {
|
||||
console.error(`[${new Date().toISOString()}] 部署失败:`, error.message);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'POST' || req.url !== '/webhook') {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', async () => {
|
||||
const signature = req.headers['x-hub-signature-256'] || req.headers['x-gitlab-token'];
|
||||
|
||||
if (SECRET !== 'your_webhook_secret_here' && signature) {
|
||||
const isValid = verifySignature(body, signature);
|
||||
if (!isValid) {
|
||||
console.warn(`[${new Date().toISOString()}] Webhook 签名验证失败`);
|
||||
res.writeHead(401);
|
||||
res.end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch (e) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
const ref = payload.ref || payload.object_attributes?.ref || 'unknown';
|
||||
console.log(`[${new Date().toISOString()}] 收到 Webhook 请求, ref: ${ref}`);
|
||||
|
||||
if (ref === 'refs/heads/main' || ref === 'main' || !ref.includes('refs/')) {
|
||||
res.writeHead(202);
|
||||
res.end(JSON.stringify({ status: 'accepted', message: '部署任务已启动' }));
|
||||
|
||||
const result = await deploy();
|
||||
console.log(`[${new Date().toISOString()}] 部署结果:`, result);
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ status: 'ignored', message: '非 main 分支推送, 忽略' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Webhook 服务器已启动, 监听端口 ${PORT}`);
|
||||
console.log(`部署目录: ${DEPLOY_DIR}`);
|
||||
console.log(`接收地址: http://your-server-ip:${PORT}/webhook`);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=MiniApp API Test Webhook Auto Deploy Service
|
||||
After=network.target docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/miniapp-api_test/deploy
|
||||
Environment="WEBHOOK_SECRET=miniapp-api-deploy-secret"
|
||||
ExecStart=/usr/bin/node /opt/miniapp-api_test/deploy/webhook-server.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,56 @@
|
||||
services:
|
||||
app-test:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: miniapp-api_test
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- PORT=3001
|
||||
- NODE_ENV=production
|
||||
- WECHAT_APPID=wxa83262674846ca1a
|
||||
- WECHAT_APPSECRET=365653aa1214a5523a6a0e7d793eec6a
|
||||
- MONGODB_URI=mongodb://mongo-test:27017/quanyixiaozhushou_test
|
||||
- JWT_SECRET=your_jwt_secret_key_here_change_in_production
|
||||
- JWT_EXPIRES_IN=7d
|
||||
- LOG_LEVEL=info
|
||||
- BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT
|
||||
- BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4
|
||||
- SERVER_URL=https://miniapp-api-test.dxz99wyr.cn
|
||||
- EXPORT_ENCRYPT_KEY=QuanYiXiaoZhuShou_2026_Secret_Key
|
||||
- ADMIN_KEY=quanyiAdmin2026
|
||||
volumes:
|
||||
- uploads_data_test:/app/public/uploads
|
||||
- ./public/avatars:/app/public/avatars
|
||||
depends_on:
|
||||
mongo-test:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
mongo-test:
|
||||
image: mongo:7.0
|
||||
container_name: miniapp-api_test-mongo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "27019:27017"
|
||||
volumes:
|
||||
- mongo_data_test:/data/db
|
||||
environment:
|
||||
- MONGO_INITDB_DATABASE=quanyixiaozhushou_test
|
||||
healthcheck:
|
||||
test: echo 'db.runCommand("ping").ok' | mongosh --quiet
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
uploads_data_test:
|
||||
mongo_data_test:
|
||||
@@ -0,0 +1,54 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
container_name: quanyixiaozhushou-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
- WECHAT_APPID=wxa83262674846ca1a
|
||||
- WECHAT_APPSECRET=365653aa1214a5523a6a0e7d793eec6a
|
||||
- MONGODB_URI=mongodb://mongo:27017/quanyixiaozhushou
|
||||
- JWT_SECRET=your_jwt_secret_key_here_change_in_production
|
||||
- JWT_EXPIRES_IN=7d
|
||||
- LOG_LEVEL=info
|
||||
- BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT
|
||||
- BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4
|
||||
- SERVER_URL=https://api-miniapp.dxz99wyr.cn
|
||||
- EXPORT_ENCRYPT_KEY=QuanYiXiaoZhuShou_2026_Secret_Key
|
||||
- ADMIN_KEY=quanyiAdmin2026
|
||||
volumes:
|
||||
- uploads_data:/app/public/uploads
|
||||
- ./public/avatars:/app/public/avatars
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
mongo:
|
||||
image: mongo:7.0
|
||||
container_name: quanyixiaozhushou-mongo
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "27018:27017"
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
environment:
|
||||
- MONGO_INITDB_DATABASE=quanyixiaozhushou
|
||||
healthcheck:
|
||||
test: echo 'db.runCommand("ping").ok' | mongosh --quiet
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
volumes:
|
||||
uploads_data:
|
||||
mongo_data:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEoQIBAAKCAQEA3dKzW/3pRiOlrVt5NYwLVo3KI4LoeQZqKn72p6MXaDuqbzif
|
||||
ah11D+8JhQnN/LrLBPrPy2e+OY3kADfxmvMCTTLZRpWry76EM72j935vqVxTWz2V
|
||||
NzMhaTfcgKeDhl9XeSduUc9yqqEYaqFuTzI9FOCz1fcpJp1gJ1vPD1ZwMKkcjlo/
|
||||
0/VaewrfOq5S9x5wVBcfEh3LjKF/bCG13uXy1ScSL0TDlsHA0gA3GUQEAzCU+R9a
|
||||
Z/CV/tPsiT6fil4youNDYAh3+PYvqRrV33qZ51eDBvRtZgGzIDqHrZ0lgaGKKN01
|
||||
XMx3tdFQbXdt+agheLej5MVU1ZJ6D/RC5ydnTQIDAQABAoH/NlHf8TPTiEeTuM4g
|
||||
3eoT3LU9XMGlEnjeYtZ8Lwa+nZw8RrNhN5iTj37YdzklMIONisbMIPaLz0m09KBs
|
||||
kX2FOSoJ5yLjlWT7CiE66a/W1k3m3kV7eprpfJWF19zHLP9aDaBz5gqNRMW4iW5f
|
||||
86OUsubY0SO37Xe2Aj9Numb9NguVzridspSMJuaisH3O6z2SIBW8Rw/4USlAyZ6m
|
||||
ZmQ74ugNhohxPsIRXc3RioXEsWxsCnEl81snB7PvXkNSO9/lCC/5aY3Elko39nGb
|
||||
9t5rSGEJfk4ZGZ7znqHx+muyQsQmD9Ds/y0YnG6p/NXBvlsFlsGvdRPxaf/a9x2U
|
||||
eftZAoGBAPc9jf/rIuDFRgREidwYhEMN1QLWfz4vzOe6yETtNyb2SNB/TMjJ6WU3
|
||||
B5ymetZgfM08GGbddemicFM/FoDHtuu46Q4mn0Ff1x0EAJGiZqogAGz9MhtRod9y
|
||||
k1zferT4PR1NrVJ5hzVCCfvkCdg5Wpl2XKEwcWdokMP9a6e7CHqZAoGBAOWunN2f
|
||||
ah6DtfXpagRnu+iQjmNwwW+YfKwoISNwWW76RHjZOuChzWbRHK6ZvSzBUlM3UGtC
|
||||
1eNXd4vuke2bWHhB+GK3cDxvJqUnRw5C4m3+914afjNZmGsYa+T4/O8KQoiXixUv
|
||||
i0f5+ML/h4nVPfNjrIBhlLuEMQBqSUn7elbVAoGAPj0+q/gTdaXztEtUsRVy5jZr
|
||||
MyWwLoV1/bflhoR459QEDIifWcSKfrJVtjeqoKD1iezg77Q8ZK5BvJMbJRwhjkGk
|
||||
Wa2bVae8zU8enYrWcWlQ8h7jKEFqkIeVVUHk7/211NSjFyoEwYF4ZfLID6iQiCVl
|
||||
uCYrxi5qkwwOt9C7l7ECgYEAtXZJVQeXzdf9sPXi3uweF9XtyT7SdRqilVl3JQqk
|
||||
ffuYkWn/DG6JW4wm/wNT5MIwCrMPBE9fsSfvuUyZWoJ7WTe1yDhpojWm8KChkPDi
|
||||
+EiSo3SG9Ib61tIKnHLjUvBmNIiWR/yyLAGgul8sdIdXVK4RIbT2z1fXZx6SHLNk
|
||||
qqkCgYB18OA1YKhXvdXkaVxau+gff0YVGYFc8bZKpisDDvnD/H16oT6wpZHDybHD
|
||||
YSAvGhcBOiAfp+SF/CRdrQxLOtfqGScvYRb+kCdt2ZLhKcmJfUg94qlDlFWed0Iz
|
||||
+UvIcfKAlzAD/IXtuGYyoK7BwKhoMxZWtpfN0d0uhGZLYmwFNg==
|
||||
-----END RSA PRIVATE KEY-----
|
||||
-1
Submodule miniapp deleted from 9dd495c30c
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api-miniapp.dxz99wyr.cn;
|
||||
|
||||
ssl_certificate /ssl/cert.pem;
|
||||
ssl_certificate_key /ssl/cret.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
alias /home/QuanYiXiaoZhuShou/Backend/public/uploads/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+6928
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "quanyixiaozhushou-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "权益小助手后端服务 - 微信小程序后台API",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"miniprogram",
|
||||
"equity",
|
||||
"backend",
|
||||
"api"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.0.3",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"sharp": "^0.33.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# 项目信息
|
||||
|
||||
## 项目概述
|
||||
|
||||
- **项目名称**:权益小助手后端服务
|
||||
- **项目类型**:后端服务(纯后端项目)
|
||||
- **工作目录**:`d:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend`
|
||||
- **当前状态**:项目初始化阶段,尚未开始开发
|
||||
|
||||
## 项目开发里程碑
|
||||
|
||||
| 里程碑 | 状态 | 计划开始时间 | 计划完成时间 | 实际完成时间 | 备注 |
|
||||
|--------|------|-------------|-------------|-------------|------|
|
||||
| 项目初始化 | 进行中 | 2026-05-01 | 2026-05-01 | - | 创建项目基础结构、配置和文档 |
|
||||
| 需求分析与设计 | 未开始 | - | - | - | 待初始化完成后进行 |
|
||||
| 数据库设计 | 未开始 | - | - | - | - |
|
||||
| 核心功能开发 | 未开始 | - | - | - | - |
|
||||
| 接口联调测试 | 未开始 | - | - | - | - |
|
||||
| 部署上线 | 未开始 | - | - | - | - |
|
||||
|
||||
## 强制要求
|
||||
|
||||
1. **上下文加载**:每次都需要在上下文中加载 `project.md`。
|
||||
|
||||
2. **意图确认**:每次接受到任务后,都需要仔细分析用户意图;如果有不理解或者认为意图模糊的时候,可以反问用户,确认好意图。
|
||||
|
||||
3. **Agent 协同**:执行任务要在当前合适的 Agent 配置中选取 Agent 调用;如果能同时协同多个 Agent,则协同调用多个 Agent 完成任务,提升任务效率。
|
||||
|
||||
4. **Git 本地提交**:每次修改都需要维护 git 仓库,提交代码修改记录到本地仓库。
|
||||
|
||||
5. **禁止擅自推送远程**:用户未主动要求,不得擅自提交到远程仓库。
|
||||
|
||||
## 核心原则
|
||||
|
||||
这是一个纯粹的后端项目,在遇到调用前端问题后,需要严格按照后端的 SDK 文档进行排查:
|
||||
|
||||
- 如果发现后端 SDK 有问题,则直接原因在后端需要进行修改。
|
||||
- 如果排查前端调用不符合 SDK 文档规范,则直接给出结论,不强行修改后端进行适配。
|
||||
@@ -0,0 +1,470 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>权益小助手 - 管理后台</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--primary: #1a73e8; --primary-hover: #1557b0; --danger: #d93025;
|
||||
--bg: #f5f7fa; --card: #fff; --text: #202124; --text-secondary: #5f6368;
|
||||
--border: #dadce0; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,.1);
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
|
||||
/* Login */
|
||||
.login-wrap { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.login-card { background: var(--card); padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,.12); width: 100%; max-width: 400px; text-align: center; }
|
||||
.login-card h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.login-card p { color: var(--text-secondary); margin-bottom: 24px; font-size: 14px; }
|
||||
.login-card input { width: 100%; padding: 12px 16px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 15px; outline: none; transition: border .2s; }
|
||||
.login-card input:focus { border-color: var(--primary); }
|
||||
.login-card button { width: 100%; margin-top: 16px; padding: 12px; background: var(--primary); color: #fff; border: none; border-radius: var(--radius); font-size: 15px; cursor: pointer; font-weight: 500; }
|
||||
.login-card button:hover { background: var(--primary-hover); }
|
||||
.login-card .err { color: var(--danger); margin-top: 12px; font-size: 14px; min-height: 20px; }
|
||||
|
||||
/* Header */
|
||||
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 0 20px; display: flex; justify-content: space-between; align-items: center; height: 56px; }
|
||||
.header h2 { font-size: 18px; font-weight: 600; }
|
||||
.header button { padding: 6px 16px; background: none; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 13px; color: var(--text-secondary); }
|
||||
.header button:hover { background: #f1f3f4; }
|
||||
|
||||
/* Stats */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.stat-card { background: var(--card); padding: 20px; border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
.stat-card .label { font-size: 13px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.stat-card .value { font-size: 28px; font-weight: 700; color: var(--text); }
|
||||
|
||||
/* Filters */
|
||||
.filters { background: var(--card); padding: 16px 20px; border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.filters input, .filters select { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; }
|
||||
.filters input:focus, .filters select:focus { border-color: var(--primary); }
|
||||
.filters input { flex: 1; min-width: 180px; }
|
||||
.filters select { min-width: 100px; }
|
||||
.filters label { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; white-space: nowrap; }
|
||||
.filters button { padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; cursor: pointer; background: var(--card); }
|
||||
.filters button:hover { background: #f1f3f4; }
|
||||
.filters .btn-search { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.filters .btn-search:hover { background: var(--primary-hover); }
|
||||
|
||||
/* Table */
|
||||
.table-wrap { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
th { text-align: left; padding: 12px 16px; background: #f8f9fa; color: var(--text-secondary); font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
th.sortable { cursor: pointer; user-select: none; transition: background .2s; }
|
||||
th.sortable:hover { background: #e8eaed; }
|
||||
th.sortable .arrow { margin-left: 4px; font-size: 11px; color: #999; }
|
||||
th.sortable.active { color: var(--primary); }
|
||||
th.sortable.active .arrow { color: var(--primary); }
|
||||
td { padding: 10px 16px; border-bottom: 1px solid #f1f3f4; }
|
||||
tr:hover td { background: #f8f9fa; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; }
|
||||
.badge-active { background: #e6f4ea; color: #137333; }
|
||||
.badge-inactive { background: #fef7e0; color: #b06000; }
|
||||
.badge-banned { background: #fce8e6; color: #c5221f; }
|
||||
.badge-vip { background: #e8f0fe; color: #1967d2; }
|
||||
.btn-detail { padding: 4px 12px; background: var(--primary); color: #fff; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; }
|
||||
.btn-detail:hover { background: var(--primary-hover); }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { padding: 16px 20px; display: flex; justify-content: center; align-items: center; gap: 12px; }
|
||||
.pagination button { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; font-size: 13px; cursor: pointer; background: var(--card); }
|
||||
.pagination button:hover:not(:disabled) { background: #f1f3f4; }
|
||||
.pagination button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.pagination span { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.4); z-index: 1000; display: flex; justify-content: center; align-items: flex-start; padding-top: 40px; }
|
||||
.modal { background: var(--card); border-radius: 12px; width: 100%; max-width: 640px; max-height: 85vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.2); }
|
||||
.modal-header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; background: var(--card); z-index: 1; }
|
||||
.modal-header h3 { font-size: 16px; }
|
||||
.modal-header button { background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-secondary); padding: 4px; }
|
||||
.modal-body { padding: 24px; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.modal-footer button { padding: 8px 20px; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
||||
.btn-save { background: var(--primary); color: #fff; border: none; }
|
||||
.btn-save:hover { background: var(--primary-hover); }
|
||||
.btn-cancel { background: var(--card); border: 1px solid var(--border); }
|
||||
.btn-cancel:hover { background: #f1f3f4; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; }
|
||||
.field input:focus, .field select:focus { border-color: var(--primary); }
|
||||
.field .ro { padding: 8px 12px; background: #f8f9fa; border-radius: 6px; font-size: 14px; word-break: break-all; color: var(--text-secondary); }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.avatar-thumb { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; border: 2px solid var(--border); }
|
||||
|
||||
/* Toast */
|
||||
.toast { position: fixed; top: 20px; right: 20px; z-index: 2000; padding: 12px 24px; border-radius: 8px; color: #fff; font-size: 14px; animation: slideIn .3s; }
|
||||
.toast-ok { background: #137333; }
|
||||
.toast-err { background: var(--danger); }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
/* Loading */
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #e3e3e3; border-top-color: var(--primary); border-radius: 50%; animation: spin .6s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-row td { text-align: center; padding: 40px; color: var(--text-secondary); }
|
||||
.empty-row td { text-align: center; padding: 40px; color: var(--text-secondary); }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login Screen -->
|
||||
<div id="loginScreen" class="login-wrap">
|
||||
<div class="login-card">
|
||||
<h1>权益小助手</h1>
|
||||
<p>管理后台</p>
|
||||
<input type="password" id="keyInput" placeholder="请输入管理密钥" onkeydown="if(event.key==='Enter')login()">
|
||||
<button onclick="login()">登 录</button>
|
||||
<div class="err" id="loginErr"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Screen -->
|
||||
<div id="appScreen" class="hidden">
|
||||
<div class="header">
|
||||
<h2>权益小助手 · 管理后台</h2>
|
||||
<button onclick="logout()">退出登录</button>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="filters">
|
||||
<input type="text" id="searchInput" placeholder="搜索 userId / 昵称..." onkeydown="if(event.key==='Enter')searchUsers()">
|
||||
<select id="statusFilter">
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">未激活</option>
|
||||
<option value="banned">封禁</option>
|
||||
</select>
|
||||
<label><input type="checkbox" id="vipFilter" onchange="searchUsers()"> 仅VIP</label>
|
||||
<button class="btn-search" onclick="searchUsers()">搜索</button>
|
||||
<button onclick="resetFilters()">重置</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="toggleSort('userId')" id="th-userId">userId</th>
|
||||
<th class="sortable" onclick="toggleSort('nickname')" id="th-nickname">昵称</th>
|
||||
<th>状态</th><th>VIP</th>
|
||||
<th class="sortable" onclick="toggleSort('ocrCount')" id="th-ocrCount">OCR</th>
|
||||
<th class="sortable" onclick="toggleSort('platformCount')" id="th-platformCount">平台</th>
|
||||
<th class="sortable" onclick="toggleSort('lastLoginAt')" id="th-lastLoginAt">最后登录</th>
|
||||
<th class="sortable" onclick="toggleSort('loginDays')" id="th-loginDays">活跃天数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody"></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Edit Modal -->
|
||||
<div class="modal-overlay hidden" id="modalOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>用户详情</h3>
|
||||
<button onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-cancel" onclick="closeModal()">取消</button>
|
||||
<button class="btn-save" id="btnSave" onclick="saveUser()">保存修改</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const S = {
|
||||
key: sessionStorage.getItem('admin_key') || '',
|
||||
page: 1,
|
||||
pages: 1,
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
editingUser: null
|
||||
};
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (S.key) headers['Authorization'] = 'Bearer ' + S.key;
|
||||
const res = await fetch(path, { headers, ...opts });
|
||||
if (res.status === 401) {
|
||||
sessionStorage.removeItem('admin_key');
|
||||
S.key = '';
|
||||
showLogin();
|
||||
throw new Error('会话已过期');
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok && !data.success) throw new Error(data.error || '请求失败');
|
||||
return data;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('loginScreen').classList.remove('hidden');
|
||||
document.getElementById('appScreen').classList.add('hidden');
|
||||
document.getElementById('loginErr').textContent = '';
|
||||
document.getElementById('keyInput').value = '';
|
||||
}
|
||||
|
||||
function showToast(msg, ok) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.remove(), 3000);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const key = document.getElementById('keyInput').value.trim();
|
||||
if (!key) { document.getElementById('loginErr').textContent = '请输入管理密钥'; return; }
|
||||
try {
|
||||
const res = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
document.getElementById('loginErr').textContent = d.error || '密钥无效';
|
||||
return;
|
||||
}
|
||||
S.key = key;
|
||||
sessionStorage.setItem('admin_key', key);
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('appScreen').classList.remove('hidden');
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
document.getElementById('loginErr').textContent = '网络错误,请重试';
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
sessionStorage.removeItem('admin_key');
|
||||
S.key = '';
|
||||
showLogin();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadStats(), loadUsers()]);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const d = await api('/api/admin/stats');
|
||||
const s = d.data;
|
||||
document.getElementById('stats').innerHTML = [
|
||||
{ label: '总用户', value: s.totalUsers },
|
||||
{ label: '活跃用户', value: s.activeUsers },
|
||||
{ label: 'VIP 用户', value: s.vipUsers },
|
||||
{ label: '封禁用户', value: s.bannedUsers },
|
||||
{ label: '今日新增', value: s.todayNewUsers }
|
||||
].map(c => `<div class="stat-card"><div class="label">${c.label}</div><div class="value">${c.value}</div></div>`).join('');
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const tbody = document.getElementById('tableBody');
|
||||
tbody.innerHTML = '<tr class="loading-row"><td colspan="9"><div class="spinner"></div> 加载中...</td></tr>';
|
||||
try {
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
const status = document.getElementById('statusFilter').value;
|
||||
const isVip = document.getElementById('vipFilter').checked;
|
||||
const params = new URLSearchParams({ page: S.page, limit: 20 });
|
||||
if (search) params.set('search', search);
|
||||
if (status) params.set('status', status);
|
||||
if (isVip) params.set('isVip', 'true');
|
||||
params.set('sortBy', S.sortBy);
|
||||
params.set('order', S.sortOrder);
|
||||
const d = await api('/api/admin/users?' + params);
|
||||
const { users, pagination } = d.data;
|
||||
S.pages = pagination.pages || 0;
|
||||
if (users.length === 0) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="9">没有找到匹配的用户</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${esc(u.userId)}</td>
|
||||
<td>${esc(u.nickname || '-')}</td>
|
||||
<td>${statusBadge(u.status)}</td>
|
||||
<td>${u.isVip ? '<span class="badge badge-vip">是</span>' : '否'}</td>
|
||||
<td>${u.ocrCount}/${u.ocrCountTotal}</td>
|
||||
<td>${u.platformCount}/${u.platformLimit}</td>
|
||||
<td>${fmtDate(u.lastLoginAt)}</td>
|
||||
<td>${u.loginDays || 1}</td>
|
||||
<td><button class="btn-detail" onclick="openModal('${u._id}')">详情</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
renderPagination();
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr class="loading-row"><td colspan="9">加载失败</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
document.getElementById('pagination').innerHTML = `
|
||||
<button onclick="prevPage()" ${S.page <= 1 ? 'disabled' : ''}>上一页</button>
|
||||
<span>第 ${S.page} 页 / 共 ${S.pages} 页</span>
|
||||
<button onclick="nextPage()" ${S.page >= S.pages ? 'disabled' : ''}>下一页</button>`;
|
||||
}
|
||||
|
||||
function toggleSort(field) {
|
||||
if (S.sortBy === field) {
|
||||
S.sortOrder = S.sortOrder === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
S.sortBy = field;
|
||||
S.sortOrder = 'desc';
|
||||
}
|
||||
S.page = 1;
|
||||
renderSortIndicators();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function renderSortIndicators() {
|
||||
document.querySelectorAll('th.sortable').forEach(th => {
|
||||
const arrow = th.querySelector('.arrow');
|
||||
if (arrow) arrow.remove();
|
||||
th.classList.remove('active');
|
||||
});
|
||||
const active = document.getElementById('th-' + S.sortBy);
|
||||
if (active) {
|
||||
active.classList.add('active');
|
||||
active.innerHTML += '<span class="arrow">' + (S.sortOrder === 'asc' ? '▲' : '▼') + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() { if (S.page > 1) { S.page--; loadUsers(); } }
|
||||
function nextPage() { if (S.page < S.pages) { S.page++; loadUsers(); } }
|
||||
|
||||
function searchUsers() { S.page = 1; loadUsers(); }
|
||||
function resetFilters() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('statusFilter').value = '';
|
||||
document.getElementById('vipFilter').checked = false;
|
||||
S.page = 1;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function openModal(id) {
|
||||
document.getElementById('modalOverlay').classList.remove('hidden');
|
||||
document.getElementById('modalBody').innerHTML = '<div class="spinner"></div> 加载中...';
|
||||
try {
|
||||
const d = await api('/api/admin/users/' + id);
|
||||
S.editingUser = d.data;
|
||||
renderModal(d.data);
|
||||
} catch (e) {
|
||||
showToast('加载用户详情失败', false);
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function renderModal(u) {
|
||||
const body = document.getElementById('modalBody');
|
||||
body.innerHTML = `
|
||||
<div class="field-row">
|
||||
<div class="field"><label>用户ID</label><div class="ro">${esc(u.userId)}</div></div>
|
||||
<div class="field"><label>OpenID</label><div class="ro">${u.openid ? u.openid.substring(0,6) + '***' + u.openid.slice(-4) : '-'}</div></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>头像</label>${u.avatarUrl ? `<img class="avatar-thumb" src="${esc(u.avatarUrl)}" onerror="this.style.display='none'">` : '<div class="ro">无</div>'}</div>
|
||||
<div class="field"><label>注册时间</label><div class="ro">${fmtDate(u.createdAt)}</div></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>最后登录</label><div class="ro">${fmtDate(u.lastLoginAt)}</div></div>
|
||||
<div class="field"><label>更新时间</label><div class="ro">${fmtDate(u.updatedAt)}</div></div>
|
||||
</div>
|
||||
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0">
|
||||
<div class="field-row">
|
||||
<div class="field"><label>昵称</label><input id="edit-nickname" value="${esc(u.nickname || '')}"></div>
|
||||
<div class="field"><label>手机号</label><input id="edit-phone" value="${esc(u.phoneNumber || '')}"></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>状态</label><select id="edit-status">
|
||||
<option value="active" ${u.status==='active'?'selected':''}>活跃</option>
|
||||
<option value="inactive" ${u.status==='inactive'?'selected':''}>未激活</option>
|
||||
<option value="banned" ${u.status==='banned'?'selected':''}>封禁</option>
|
||||
</select></div>
|
||||
<div class="field"><label>VIP</label><select id="edit-vip" onchange="toggleVipExpire()">
|
||||
<option value="true" ${u.isVip?'selected':''}>是</option>
|
||||
<option value="false" ${!u.isVip?'selected':''}>否</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="field" id="vipExpireField" style="${u.isVip?'':'display:none'}">
|
||||
<label>VIP 到期时间</label>
|
||||
<input type="date" id="edit-vipExpire" value="${u.vipExpireAt ? u.vipExpireAt.substring(0,10) : ''}">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>OCR 剩余次数</label><input type="number" id="edit-ocrCount" value="${u.ocrCount || 0}" min="0"></div>
|
||||
<div class="field"><label>OCR 总次数</label><input type="number" id="edit-ocrTotal" value="${u.ocrCountTotal || 0}" min="0"></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>平台限额</label><input type="number" id="edit-platformLimit" value="${u.platformLimit || 0}" min="0"></div>
|
||||
<div class="field"><label>当前平台数</label><input type="number" id="edit-platformCount" value="${u.platformCount || 0}" min="0"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleVipExpire() {
|
||||
document.getElementById('vipExpireField').style.display =
|
||||
document.getElementById('edit-vip').value === 'true' ? '' : 'none';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modalOverlay').classList.add('hidden');
|
||||
S.editingUser = null;
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!S.editingUser) return;
|
||||
const btn = document.getElementById('btnSave');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '保存中...';
|
||||
try {
|
||||
const body = {
|
||||
nickname: document.getElementById('edit-nickname').value.trim(),
|
||||
phoneNumber: document.getElementById('edit-phone').value.trim(),
|
||||
status: document.getElementById('edit-status').value,
|
||||
isVip: document.getElementById('edit-vip').value === 'true',
|
||||
vipExpireAt: document.getElementById('edit-vip').value === 'true' ? document.getElementById('edit-vipExpire').value || null : null,
|
||||
ocrCount: parseInt(document.getElementById('edit-ocrCount').value) || 0,
|
||||
ocrCountTotal: parseInt(document.getElementById('edit-ocrTotal').value) || 0,
|
||||
platformLimit: parseInt(document.getElementById('edit-platformLimit').value) || 0,
|
||||
platformCount: parseInt(document.getElementById('edit-platformCount').value) || 0
|
||||
};
|
||||
await api('/api/admin/users/' + S.editingUser._id, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
closeModal();
|
||||
showToast('保存成功', true);
|
||||
loadUsers();
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
showToast(e.message || '保存失败', false);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '保存修改';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function statusBadge(s) {
|
||||
const map = { active: 'badge-active', inactive: 'badge-inactive', banned: 'badge-banned' };
|
||||
const label = { active: '活跃', inactive: '未激活', banned: '封禁' };
|
||||
return `<span class="badge ${map[s] || ''}">${label[s] || s}</span>`;
|
||||
}
|
||||
function fmtDate(d) { if (!d) return '-'; const t = new Date(d); return t.getFullYear()+'-'+String(t.getMonth()+1).padStart(2,'0')+'-'+String(t.getDate()).padStart(2,'0')+' '+String(t.getHours()).padStart(2,'0')+':'+String(t.getMinutes()).padStart(2,'0'); }
|
||||
|
||||
// Init
|
||||
if (S.key) {
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('appScreen').classList.remove('hidden');
|
||||
renderSortIndicators();
|
||||
loadAll();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
const mongoose = require('mongoose');
|
||||
require('dotenv').config();
|
||||
|
||||
async function assignUserIds() {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log('数据库已连接');
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
const users = await db.collection('users').find({}).toArray();
|
||||
console.log(`找到 ${users.length} 个用户`);
|
||||
|
||||
for (let i = 0; i < users.length; i++) {
|
||||
const user = users[i];
|
||||
const userId = (i + 1).toString().padStart(8, '0');
|
||||
|
||||
await db.collection('users').updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: { userId } }
|
||||
);
|
||||
|
||||
console.log(`已分配: ${user.nickname || '(空昵称)'} -> ${userId}`);
|
||||
}
|
||||
|
||||
console.log('全部分配完成!');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('错误:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
assignUserIds();
|
||||
@@ -0,0 +1,31 @@
|
||||
const mongoose = require('mongoose');
|
||||
const User = require('../src/models/User');
|
||||
const UserEquity = require('../src/models/UserEquity');
|
||||
|
||||
async function fixPlatformCount() {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/member-assistant');
|
||||
console.log('MongoDB connected');
|
||||
|
||||
const users = await User.find({}).select('_id');
|
||||
console.log(`Found ${users.length} users`);
|
||||
|
||||
let updated = 0;
|
||||
for (const user of users) {
|
||||
const count = await UserEquity.countDocuments({ owner: user._id });
|
||||
await User.findByIdAndUpdate(user._id, { platformCount: count });
|
||||
updated++;
|
||||
if (updated % 100 === 0) {
|
||||
console.log(`Progress: ${updated}/${users.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Done! Updated ${updated} users.`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixPlatformCount();
|
||||
@@ -0,0 +1,31 @@
|
||||
const mongoose = require('mongoose');
|
||||
require('dotenv').config();
|
||||
|
||||
async function setVip() {
|
||||
try {
|
||||
await mongoose.connect(process.env.MONGODB_URI);
|
||||
console.log('数据库已连接');
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
const result = await db.collection('users').updateOne(
|
||||
{ userId: '00000001' },
|
||||
{ $set: { isVip: true, vipExpireAt: new Date('2026-12-31') } }
|
||||
);
|
||||
console.log('更新结果:', result.modifiedCount);
|
||||
|
||||
const user = await db.collection('users').findOne({ userId: '00000001' });
|
||||
console.log('用户信息:', JSON.stringify({
|
||||
nickname: user.nickname,
|
||||
userId: user.userId,
|
||||
isVip: user.isVip,
|
||||
vipExpireAt: user.vipExpireAt
|
||||
}, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('错误:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
setVip();
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const connectDB = require('./config/database');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const { notFound } = require('./middleware/notFound');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const equityRoutes = require('./routes/equity');
|
||||
const userRoutes = require('./routes/user');
|
||||
const tradeRoutes = require('./routes/trade');
|
||||
const userEquityRoutes = require('./routes/userEquity');
|
||||
const ocrRoutes = require('./routes/ocr');
|
||||
const presetRoutes = require('./routes/preset');
|
||||
const equityDetailRoutes = require('./routes/equityDetail');
|
||||
const membershipRoutes = require('./routes/membership');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
const subscribeRoutes = require('./routes/subscribe');
|
||||
const uploadRoutes = require('./routes/upload');
|
||||
const wechatMessageRoutes = require('./routes/wechatMessage');
|
||||
|
||||
const cron = require('node-cron');
|
||||
|
||||
const app = express();
|
||||
connectDB();
|
||||
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrcAttr: ["'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"],
|
||||
imgSrc: ["*", "data:", "blob:"],
|
||||
connectSrc: ["'self'", "https:", "http:"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
app.use(morgan('dev'));
|
||||
}
|
||||
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
||||
app.use('/admin', express.static(path.join(__dirname, '../public/admin')));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'quanyixiaozhushou-backend'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/equity', equityRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
app.use('/api/trade', tradeRoutes);
|
||||
app.use('/api/user-equity', userEquityRoutes);
|
||||
app.use('/api/ocr', ocrRoutes);
|
||||
app.use('/api/presets', presetRoutes);
|
||||
app.use('/api/equity-detail', equityDetailRoutes);
|
||||
app.use('/api/membership', membershipRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/subscribe', subscribeRoutes);
|
||||
app.use('/api/upload', uploadRoutes);
|
||||
|
||||
app.use('/wechat-message', wechatMessageRoutes);
|
||||
|
||||
const adminRoutes = require('./routes/admin');
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
const { sendExpiryReminders } = require('./routes/subscribe');
|
||||
cron.schedule('40 9 * * *', async () => {
|
||||
console.log(`[${new Date().toISOString()}] ⏰ 到期提醒定时任务开始执行...`);
|
||||
try {
|
||||
const results = await sendExpiryReminders();
|
||||
console.log(`[${new Date().toISOString()}] ✅ 到期提醒执行完成: 共${results.total}条, 发送成功${results.sent}条, 失败${results.failed}条`);
|
||||
if (results.errors.length > 0) {
|
||||
console.error('失败详情:', JSON.stringify(results.errors.slice(0, 10)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${new Date().toISOString()}] ❌ 到期提醒执行失败:`, error.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'Asia/Shanghai'
|
||||
});
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 权益小助手后端服务运行在端口 ${PORT}`);
|
||||
console.log(`📊 环境: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🌐 访问地址: https://api-miniapp.dxz99wyr.cn`);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('💥 未捕获异常 - 进程即将退出:', error.message);
|
||||
console.error('Stack:', error.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('💥 未处理的Promise拒绝:', reason);
|
||||
if (reason && reason.stack) console.error('Stack:', reason.stack);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,26 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const connectDB = async () => {
|
||||
try {
|
||||
const conn = await mongoose.connect(process.env.MONGODB_URI, {
|
||||
// Mongoose 6+ 不需要这些选项,但保留以备不时之需
|
||||
// useNewUrlParser: true,
|
||||
// useUnifiedTopology: true,
|
||||
});
|
||||
|
||||
console.log(`✅ MongoDB 连接成功: ${conn.connection.host}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ MongoDB 连接失败: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
mongoose.connection.on('error', (err) => {
|
||||
console.error(`MongoDB 连接错误: ${err}`);
|
||||
});
|
||||
|
||||
mongoose.connection.on('disconnected', () => {
|
||||
console.warn('MongoDB 连接已断开');
|
||||
});
|
||||
|
||||
module.exports = connectDB;
|
||||
@@ -0,0 +1,13 @@
|
||||
const adminAuth = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: '未提供管理密钥' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (token !== process.env.ADMIN_KEY) {
|
||||
return res.status(401).json({ success: false, error: '管理密钥无效' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { adminAuth };
|
||||
@@ -0,0 +1,42 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
try {
|
||||
let token;
|
||||
|
||||
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未授权,请先登录'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = await User.findById(decoded.id);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '未授权,token无效'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { auth };
|
||||
@@ -0,0 +1,39 @@
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
let error = { ...err };
|
||||
error.message = err.message;
|
||||
|
||||
console.error('错误详情:', err);
|
||||
|
||||
if (err.name === 'CastError') {
|
||||
const message = '资源未找到';
|
||||
error = { message, statusCode: 404 };
|
||||
}
|
||||
|
||||
if (err.code === 11000) {
|
||||
const message = '重复字段值 entered';
|
||||
error = { message, statusCode: 400 };
|
||||
}
|
||||
|
||||
if (err.name === 'ValidationError') {
|
||||
const message = Object.values(err.errors).map(val => val.message).join(', ');
|
||||
error = { message, statusCode: 400 };
|
||||
}
|
||||
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
const message = '无效的token';
|
||||
error = { message, statusCode: 401 };
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
const message = 'token已过期';
|
||||
error = { message, statusCode: 401 };
|
||||
}
|
||||
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '服务器内部错误',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
const notFound = (req, res, next) => {
|
||||
const error = new Error(`未找到路由 - ${req.originalUrl}`);
|
||||
res.status(404);
|
||||
next(error);
|
||||
};
|
||||
|
||||
module.exports = { notFound };
|
||||
@@ -0,0 +1,80 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const equitySchema = new mongoose.Schema({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['coupon', 'membership', 'discount', 'gift', 'other'],
|
||||
required: true
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['taobao', 'jd', 'pdd', 'meituan', 'eleme', 'douyin', 'kuaishou', 'other']
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
unit: {
|
||||
type: String,
|
||||
default: '元'
|
||||
},
|
||||
validStart: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
validEnd: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'used', 'expired', 'transferred'],
|
||||
default: 'active'
|
||||
},
|
||||
owner: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
source: {
|
||||
type: String,
|
||||
enum: ['manual', 'import', 'purchase', 'transfer'],
|
||||
default: 'manual'
|
||||
},
|
||||
images: [{
|
||||
type: String
|
||||
}],
|
||||
tags: [{
|
||||
type: String
|
||||
}],
|
||||
isTransferable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
transferPrice: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
metadata: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
default: {}
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
equitySchema.index({ owner: 1, status: 1 });
|
||||
equitySchema.index({ platform: 1, type: 1 });
|
||||
equitySchema.index({ validEnd: 1 });
|
||||
|
||||
module.exports = mongoose.model('Equity', equitySchema);
|
||||
@@ -0,0 +1,72 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const equityDetailSchema = new mongoose.Schema({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
unique: true
|
||||
},
|
||||
platformIcon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
platformColor: {
|
||||
type: String,
|
||||
default: '#1890ff'
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
typeLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
summary: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
summaryGenerated: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
summaryApproved: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
detail: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
detailImages: [{
|
||||
type: String
|
||||
}],
|
||||
benefits: [{
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}],
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive'],
|
||||
default: 'active'
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
equityDetailSchema.index({ platform: 1 });
|
||||
equityDetailSchema.index({ status: 1 });
|
||||
|
||||
module.exports = mongoose.model('EquityDetail', equityDetailSchema);
|
||||
@@ -0,0 +1,76 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const benefitSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'year'
|
||||
},
|
||||
typeLabel: {
|
||||
type: String,
|
||||
default: '年卡'
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}, { _id: true });
|
||||
|
||||
const platformPresetSchema = new mongoose.Schema({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
platformIcon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
platformColor: {
|
||||
type: String,
|
||||
default: '#1890ff'
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
typeLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
benefits: [benefitSchema],
|
||||
isHot: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive'],
|
||||
default: 'active'
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
platformPresetSchema.index({ platform: 1 });
|
||||
platformPresetSchema.index({ isHot: 1, sortOrder: 1 });
|
||||
platformPresetSchema.index({ status: 1 });
|
||||
|
||||
module.exports = mongoose.model('PlatformPreset', platformPresetSchema);
|
||||
@@ -0,0 +1,84 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const tradeSchema = new mongoose.Schema({
|
||||
equity: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Equity',
|
||||
required: true
|
||||
},
|
||||
seller: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
buyer: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
default: null
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 0
|
||||
},
|
||||
originalPrice: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'paid', 'completed', 'cancelled', 'disputed'],
|
||||
default: 'pending'
|
||||
},
|
||||
tradeType: {
|
||||
type: String,
|
||||
enum: ['sale', 'auction', 'exchange'],
|
||||
default: 'sale'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
enum: ['wechat_pay', 'alipay', 'balance', 'other'],
|
||||
default: 'wechat_pay'
|
||||
},
|
||||
paidAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
completedAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
cancelledAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
cancelReason: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
rating: {
|
||||
score: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
max: 5,
|
||||
default: null
|
||||
},
|
||||
comment: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
tradeSchema.index({ seller: 1, status: 1 });
|
||||
tradeSchema.index({ buyer: 1, status: 1 });
|
||||
tradeSchema.index({ equity: 1 });
|
||||
tradeSchema.index({ status: 1, createdAt: -1 });
|
||||
|
||||
module.exports = mongoose.model('Trade', tradeSchema);
|
||||
@@ -0,0 +1,106 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
unionid: {
|
||||
type: String,
|
||||
sparse: true,
|
||||
index: true
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
nickname: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
phoneNumber: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
profile: {
|
||||
gender: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
country: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
province: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
city: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'banned'],
|
||||
default: 'active'
|
||||
},
|
||||
lastLoginAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
isVip: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
vipExpireAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
ocrCount: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
ocrCountTotal: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
ocrCountResetAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
platformLimit: {
|
||||
type: Number,
|
||||
default: 15
|
||||
},
|
||||
platformCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
loginDays: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
lastLoginDay: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
userSchema.pre('save', function(next) {
|
||||
if (this.isModified('lastLoginAt')) {
|
||||
this.lastLoginAt = Date.now();
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
@@ -0,0 +1,160 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const benefitSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
typeLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
expireDate: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
used: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
usedTime: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
times: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
usedTimes: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
usedAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
rechargeAmount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: '¥'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
autoRenew: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
autoRenewCycle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
renewReminderDismissed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, { _id: true });
|
||||
|
||||
const userEquitySchema = new mongoose.Schema({
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
platformType: {
|
||||
type: String,
|
||||
default: 'online'
|
||||
},
|
||||
expireDate: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
price: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
brandIcon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
brandIconImage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
brandColor: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
benefits: [benefitSchema],
|
||||
owner: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'used', 'expired'],
|
||||
default: 'active'
|
||||
},
|
||||
note: {
|
||||
text: { type: String, default: '' },
|
||||
images: [{ type: String }]
|
||||
},
|
||||
hasUsedBenefit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
createTime: {
|
||||
type: String,
|
||||
default: () => new Date().toISOString()
|
||||
},
|
||||
updateTime: {
|
||||
type: String,
|
||||
default: () => new Date().toISOString()
|
||||
},
|
||||
syncedAt: {
|
||||
type: String,
|
||||
default: () => new Date().toISOString()
|
||||
}
|
||||
}, {
|
||||
timestamps: false
|
||||
});
|
||||
|
||||
userEquitySchema.index({ owner: 1, status: 1 });
|
||||
userEquitySchema.index({ platform: 1 });
|
||||
userEquitySchema.index({ expireDate: 1 });
|
||||
|
||||
userEquitySchema.pre('save', function(next) {
|
||||
this.updateTime = new Date().toISOString();
|
||||
next();
|
||||
});
|
||||
|
||||
userEquitySchema.pre('findOneAndUpdate', function(next) {
|
||||
this.set({ updateTime: new Date().toISOString() });
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('UserEquity', userEquitySchema);
|
||||
@@ -0,0 +1,57 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const userSubscriptionSchema = new mongoose.Schema({
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
openid: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
templateId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
scene: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['version-update', 'expiry-reminder'],
|
||||
default: 'version-update'
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'expired', 'used'],
|
||||
default: 'active'
|
||||
},
|
||||
subscribedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
expiredAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
usedAt: {
|
||||
type: Date,
|
||||
default: null
|
||||
},
|
||||
lastSentDay: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
userSubscriptionSchema.index({ userId: 1, templateId: 1 });
|
||||
userSubscriptionSchema.index({ type: 1, status: 1 });
|
||||
|
||||
module.exports = mongoose.model('UserSubscription', userSubscriptionSchema);
|
||||
@@ -0,0 +1,41 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const versionLogSchema = new mongoose.Schema({
|
||||
version: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
versionName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
releaseDate: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
features: [{
|
||||
type: String
|
||||
}],
|
||||
fixes: [{
|
||||
type: String
|
||||
}],
|
||||
improvements: [{
|
||||
type: String
|
||||
}],
|
||||
isPublished: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
versionLogSchema.index({ version: 1 });
|
||||
versionLogSchema.index({ isPublished: 1, sortOrder: -1 });
|
||||
|
||||
module.exports = mongoose.model('VersionLog', versionLogSchema);
|
||||
@@ -0,0 +1,163 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const User = require('../models/User');
|
||||
const { adminAuth } = require('../middleware/adminAuth');
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { key } = req.body;
|
||||
if (!key) {
|
||||
return res.status(400).json({ success: false, error: '缺少管理密钥' });
|
||||
}
|
||||
if (key !== process.env.ADMIN_KEY) {
|
||||
return res.status(401).json({ success: false, error: '管理密钥无效' });
|
||||
}
|
||||
res.json({ success: true, data: { message: '验证成功' } });
|
||||
});
|
||||
|
||||
router.get('/stats', adminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalUsers, activeUsers, bannedUsers, vipUsers, todayNewUsers] = await Promise.all([
|
||||
User.countDocuments(),
|
||||
User.countDocuments({ status: 'active' }),
|
||||
User.countDocuments({ status: 'banned' }),
|
||||
User.countDocuments({ isVip: true }),
|
||||
User.countDocuments({ createdAt: { $gte: today } })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
bannedUsers,
|
||||
vipUsers,
|
||||
todayNewUsers
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', adminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search,
|
||||
status,
|
||||
isVip,
|
||||
sortBy = 'createdAt',
|
||||
order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const query = {};
|
||||
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ userId: { $regex: search, $options: 'i' } },
|
||||
{ nickname: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
if (status && ['active', 'inactive', 'banned'].includes(status)) {
|
||||
query.status = status;
|
||||
}
|
||||
|
||||
if (isVip === 'true') {
|
||||
query.isVip = true;
|
||||
} else if (isVip === 'false') {
|
||||
query.isVip = false;
|
||||
}
|
||||
|
||||
const sortOrder = order === 'asc' ? 1 : -1;
|
||||
const sortField = ['userId', 'createdAt', 'lastLoginAt', 'nickname', 'ocrCount', 'platformCount', 'loginDays'].includes(sortBy) ? sortBy : 'createdAt';
|
||||
|
||||
const pageNum = Math.max(1, parseInt(page));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 20));
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
User.find(query)
|
||||
.select('-openid -unionid -__v')
|
||||
.sort({ [sortField]: sortOrder })
|
||||
.skip(skip)
|
||||
.limit(limitNum)
|
||||
.lean(),
|
||||
User.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
pages: Math.ceil(total / limitNum)
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users/:id', adminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.params.id).select('-__v').lean();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '用户不存在' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/users/:id', adminAuth, async (req, res, next) => {
|
||||
try {
|
||||
const allowedUpdates = [
|
||||
'nickname', 'phoneNumber', 'status', 'isVip',
|
||||
'vipExpireAt', 'ocrCount', 'ocrCountTotal',
|
||||
'platformLimit', 'platformCount'
|
||||
];
|
||||
|
||||
const updates = {};
|
||||
for (const key of allowedUpdates) {
|
||||
if (req.body[key] !== undefined) {
|
||||
updates[key] = req.body[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.status && !['active', 'inactive', 'banned'].includes(updates.status)) {
|
||||
return res.status(400).json({ success: false, error: '无效的用户状态' });
|
||||
}
|
||||
|
||||
if (updates.isVip === false && !req.body.vipExpireAt) {
|
||||
updates.vipExpireAt = null;
|
||||
}
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
req.params.id,
|
||||
{ $set: updates },
|
||||
{ new: true, runValidators: true }
|
||||
).select('-__v').lean();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: '用户不存在' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: user });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,158 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
|
||||
async function generateUserId() {
|
||||
const lastUser = await User.findOne({ userId: { $exists: true } })
|
||||
.sort({ userId: -1 })
|
||||
.select('userId')
|
||||
.lean();
|
||||
|
||||
let nextNumber = 1;
|
||||
if (lastUser && lastUser.userId) {
|
||||
const lastNumber = parseInt(lastUser.userId, 10);
|
||||
if (!isNaN(lastNumber)) {
|
||||
nextNumber = lastNumber + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return nextNumber.toString().padStart(8, '0');
|
||||
}
|
||||
|
||||
router.post('/wechat-login', async (req, res, next) => {
|
||||
try {
|
||||
const { code, userInfo } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少微信登录code'
|
||||
});
|
||||
}
|
||||
|
||||
const appid = process.env.WECHAT_APPID;
|
||||
const secret = process.env.WECHAT_APPSECRET;
|
||||
|
||||
const wxResponse = await axios.get('https://api.weixin.qq.com/sns/jscode2session', {
|
||||
params: {
|
||||
appid,
|
||||
secret,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code'
|
||||
}
|
||||
});
|
||||
|
||||
const { openid, session_key, unionid, errcode, errmsg } = wxResponse.data;
|
||||
|
||||
if (errcode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `微信登录失败: ${errmsg}`,
|
||||
wxErrorCode: errcode
|
||||
});
|
||||
}
|
||||
|
||||
let user = await User.findOne({ openid });
|
||||
|
||||
if (!user) {
|
||||
const userId = await generateUserId();
|
||||
|
||||
user = await User.create({
|
||||
openid,
|
||||
unionid: unionid || undefined,
|
||||
userId,
|
||||
nickname: userInfo?.nickName || '',
|
||||
avatarUrl: userInfo?.avatarUrl || '',
|
||||
profile: {
|
||||
gender: userInfo?.gender || 0,
|
||||
country: userInfo?.country || '',
|
||||
province: userInfo?.province || '',
|
||||
city: userInfo?.city || ''
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (userInfo) {
|
||||
user.nickname = userInfo.nickName || user.nickname;
|
||||
user.avatarUrl = userInfo.avatarUrl || user.avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
if (user.lastLoginDay !== today) {
|
||||
user.loginDays = (user.loginDays || 1) + 1;
|
||||
user.lastLoginDay = today;
|
||||
}
|
||||
user.lastLoginAt = now;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user._id, openid: user.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
userId: user.userId,
|
||||
nickname: user.nickname,
|
||||
avatarUrl: user.avatarUrl,
|
||||
status: user.status,
|
||||
isVip: user.isVip,
|
||||
vipExpireAt: user.vipExpireAt,
|
||||
ocrCount: user.ocrCount,
|
||||
ocrCountTotal: user.ocrCountTotal,
|
||||
platformLimit: user.platformLimit,
|
||||
platformCount: user.platformCount
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/refresh-token', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少token'
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { ignoreExpiration: true });
|
||||
const user = await User.findById(decoded.id);
|
||||
|
||||
if (!user || user.status !== 'active') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: '用户不存在或已被禁用'
|
||||
});
|
||||
}
|
||||
|
||||
const newToken = jwt.sign(
|
||||
{ id: user._id, openid: user.openid },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { token: newToken }
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,197 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const Equity = require('../models/Equity');
|
||||
|
||||
router.get('/', auth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
platform,
|
||||
type,
|
||||
sortBy = 'createdAt',
|
||||
order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const query = { owner: req.user._id };
|
||||
|
||||
if (status) query.status = status;
|
||||
if (platform) query.platform = platform;
|
||||
if (type) query.type = type;
|
||||
|
||||
const sortOrder = order === 'asc' ? 1 : -1;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const [equities, total] = await Promise.all([
|
||||
Equity.find(query)
|
||||
.sort({ [sortBy]: sortOrder })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.lean(),
|
||||
Equity.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: equities,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', auth, async (req, res, next) => {
|
||||
try {
|
||||
const equity = await Equity.findOne({
|
||||
_id: req.params.id,
|
||||
owner: req.user._id
|
||||
});
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', auth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
platform,
|
||||
value,
|
||||
unit,
|
||||
validStart,
|
||||
validEnd,
|
||||
images,
|
||||
tags,
|
||||
isTransferable,
|
||||
transferPrice,
|
||||
metadata
|
||||
} = req.body;
|
||||
|
||||
if (!title || !type || !platform || !validStart || !validEnd) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必填字段'
|
||||
});
|
||||
}
|
||||
|
||||
const equity = await Equity.create({
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
platform,
|
||||
value,
|
||||
unit,
|
||||
validStart: new Date(validStart),
|
||||
validEnd: new Date(validEnd),
|
||||
owner: req.user._id,
|
||||
images,
|
||||
tags,
|
||||
isTransferable,
|
||||
transferPrice,
|
||||
metadata
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', auth, async (req, res, next) => {
|
||||
try {
|
||||
const equity = await Equity.findOneAndUpdate(
|
||||
{ _id: req.params.id, owner: req.user._id },
|
||||
{ ...req.body, updatedAt: new Date() },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在或无权限修改'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', auth, async (req, res, next) => {
|
||||
try {
|
||||
const equity = await Equity.findOneAndDelete({
|
||||
_id: req.params.id,
|
||||
owner: req.user._id
|
||||
});
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在或无权限删除'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '权益已删除'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/platforms/summary', auth, async (req, res, next) => {
|
||||
try {
|
||||
const summary = await Equity.aggregate([
|
||||
{ $match: { owner: req.user._id } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$platform',
|
||||
count: { $sum: 1 },
|
||||
totalValue: { $sum: '$value' }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const EquityDetail = require('../models/EquityDetail');
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { platform } = req.query;
|
||||
const query = { status: 'active' };
|
||||
|
||||
if (platform) {
|
||||
query.platform = platform;
|
||||
}
|
||||
|
||||
const details = await EquityDetail.find(query)
|
||||
.sort({ platform: 1 })
|
||||
.lean();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: details
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:platform', async (req, res, next) => {
|
||||
try {
|
||||
const { platform } = req.params;
|
||||
|
||||
const detail = await EquityDetail.findOne({
|
||||
platform,
|
||||
status: 'active'
|
||||
}).lean();
|
||||
|
||||
if (!detail) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益详情不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: detail
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,79 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const User = require('../models/User');
|
||||
const UserEquity = require('../models/UserEquity');
|
||||
|
||||
router.get('/status', auth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
const now = new Date();
|
||||
const resetDate = new Date(user.ocrCountResetAt);
|
||||
const isNewMonth = now.getFullYear() !== resetDate.getFullYear() ||
|
||||
now.getMonth() !== resetDate.getMonth();
|
||||
|
||||
if (isNewMonth) {
|
||||
user.ocrCount = user.ocrCountTotal;
|
||||
user.ocrCountResetAt = now;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
const platformCount = await UserEquity.countDocuments({ owner: req.user._id });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isVip: user.isVip,
|
||||
vipExpireAt: user.vipExpireAt,
|
||||
ocrCount: user.ocrCount,
|
||||
ocrCountTotal: user.ocrCountTotal,
|
||||
ocrCountResetAt: user.ocrCountResetAt,
|
||||
platformLimit: user.platformLimit,
|
||||
platformCount: platformCount,
|
||||
remainingPlatforms: user.isVip ? -1 : Math.max(0, user.platformLimit - platformCount)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upgrade', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { duration = 1 } = req.body;
|
||||
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
const now = new Date();
|
||||
let currentExpireAt = user.vipExpireAt ? new Date(user.vipExpireAt) : now;
|
||||
|
||||
if (currentExpireAt < now) {
|
||||
currentExpireAt = now;
|
||||
}
|
||||
|
||||
currentExpireAt.setMonth(currentExpireAt.getMonth() + duration);
|
||||
|
||||
user.isVip = true;
|
||||
user.vipExpireAt = currentExpireAt;
|
||||
user.ocrCountTotal = 100;
|
||||
user.ocrCount = 100;
|
||||
user.platformLimit = -1;
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isVip: user.isVip,
|
||||
vipExpireAt: user.vipExpireAt,
|
||||
ocrCount: user.ocrCount,
|
||||
ocrCountTotal: user.ocrCountTotal,
|
||||
platformLimit: user.platformLimit
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,105 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const User = require('../models/User');
|
||||
const { recognizeText, extractMembershipInfo } = require('../services/ocrService');
|
||||
|
||||
router.post('/recognize', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { image } = req.body;
|
||||
|
||||
if (!image) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少图片数据'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
const now = new Date();
|
||||
const resetDate = new Date(user.ocrCountResetAt);
|
||||
const isNewMonth = now.getFullYear() !== resetDate.getFullYear() ||
|
||||
now.getMonth() !== resetDate.getMonth();
|
||||
|
||||
if (isNewMonth) {
|
||||
user.ocrCount = user.ocrCountTotal;
|
||||
user.ocrCountResetAt = now;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
if (user.ocrCount <= 0) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '本月OCR次数已用完,请升级会员获取更多次数',
|
||||
data: {
|
||||
ocrCount: 0,
|
||||
ocrCountTotal: user.ocrCountTotal,
|
||||
isVip: user.isVip
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const imageBase64 = image.replace(/^data:image\/\w+;base64,/, '');
|
||||
|
||||
const ocrResult = await recognizeText(imageBase64, {
|
||||
language_type: 'CHN_ENG'
|
||||
});
|
||||
|
||||
if (ocrResult.error_code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `OCR识别失败: ${ocrResult.error_msg}`,
|
||||
data: ocrResult
|
||||
});
|
||||
}
|
||||
|
||||
const extractedInfo = extractMembershipInfo(ocrResult);
|
||||
|
||||
user.ocrCount -= 1;
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: extractedInfo,
|
||||
meta: {
|
||||
ocrCount: user.ocrCount,
|
||||
ocrCountTotal: user.ocrCountTotal,
|
||||
isVip: user.isVip
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/quota', auth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
const now = new Date();
|
||||
const resetDate = new Date(user.ocrCountResetAt);
|
||||
const isNewMonth = now.getFullYear() !== resetDate.getFullYear() ||
|
||||
now.getMonth() !== resetDate.getMonth();
|
||||
|
||||
if (isNewMonth) {
|
||||
user.ocrCount = user.ocrCountTotal;
|
||||
user.ocrCountResetAt = now;
|
||||
await user.save();
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
ocrCount: user.ocrCount,
|
||||
ocrCountTotal: user.ocrCountTotal,
|
||||
isVip: user.isVip,
|
||||
resetAt: user.ocrCountResetAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,103 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const PlatformPreset = require('../models/PlatformPreset');
|
||||
const UserEquity = require('../models/UserEquity');
|
||||
const User = require('../models/User');
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const presets = await PlatformPreset.find({ status: 'active' })
|
||||
.sort({ isHot: -1, sortOrder: 1 })
|
||||
.lean();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: presets
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/hot', async (req, res, next) => {
|
||||
try {
|
||||
const presets = await PlatformPreset.find({ status: 'active', isHot: true })
|
||||
.sort({ sortOrder: 1 })
|
||||
.lean();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: presets
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import/:id', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { expireDate, price } = req.body;
|
||||
|
||||
const preset = await PlatformPreset.findById(id);
|
||||
|
||||
if (!preset) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '预设平台不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
if (!user.isVip) {
|
||||
const userEquityCount = await UserEquity.countDocuments({ owner: req.user._id });
|
||||
if (userEquityCount >= user.platformLimit) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '非会员最多只能添加15个平台,请升级会员'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!expireDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少到期时间'
|
||||
});
|
||||
}
|
||||
|
||||
const equityData = {
|
||||
platform: preset.platform,
|
||||
type: preset.type,
|
||||
expireDate,
|
||||
price: parseFloat(price) || preset.price || 0,
|
||||
benefits: preset.benefits.map(b => ({
|
||||
name: b.name,
|
||||
type: b.type,
|
||||
typeLabel: b.typeLabel,
|
||||
expireDate,
|
||||
used: false,
|
||||
usedTime: null
|
||||
})),
|
||||
owner: req.user._id,
|
||||
status: 'active',
|
||||
note: preset.description || ''
|
||||
};
|
||||
|
||||
const equity = await UserEquity.create(equityData);
|
||||
|
||||
user.platformCount = await UserEquity.countDocuments({ owner: req.user._id });
|
||||
await user.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,206 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const VersionLog = require('../models/VersionLog');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32;
|
||||
const IV_LENGTH = 16;
|
||||
const SALT_LENGTH = 64;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getEncryptionKey() {
|
||||
const envKey = process.env.EXPORT_ENCRYPT_KEY;
|
||||
if (envKey) {
|
||||
return crypto.scryptSync(envKey, 'salt', KEY_LENGTH);
|
||||
}
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
function encryptData(data) {
|
||||
const key = getEncryptionKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const jsonData = JSON.stringify(data);
|
||||
let encrypted = cipher.update(jsonData, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const result = {
|
||||
salt: salt.toString('hex'),
|
||||
iv: iv.toString('hex'),
|
||||
tag: tag.toString('hex'),
|
||||
data: encrypted
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(result)).toString('base64');
|
||||
}
|
||||
|
||||
function decryptData(encryptedBase64) {
|
||||
try {
|
||||
const encryptedJson = Buffer.from(encryptedBase64, 'base64').toString('utf8');
|
||||
const encrypted = JSON.parse(encryptedJson);
|
||||
|
||||
const key = getEncryptionKey();
|
||||
const iv = Buffer.from(encrypted.iv, 'hex');
|
||||
const tag = Buffer.from(encrypted.tag, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
let decrypted = decipher.update(encrypted.data, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return JSON.parse(decrypted);
|
||||
} catch (error) {
|
||||
throw new Error('数据解密失败,文件可能已损坏或被篡改');
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/versions', async (req, res, next) => {
|
||||
try {
|
||||
const versions = await VersionLog.find({ isPublished: true })
|
||||
.sort({ sortOrder: -1, releaseDate: -1 })
|
||||
.lean();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: versions
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/versions/:version', async (req, res, next) => {
|
||||
try {
|
||||
const { version } = req.params;
|
||||
|
||||
const versionLog = await VersionLog.findOne({ version }).lean();
|
||||
|
||||
if (!versionLog) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '版本记录不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: versionLog
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/export', auth, async (req, res, next) => {
|
||||
try {
|
||||
const UserEquity = require('../models/UserEquity');
|
||||
|
||||
const equities = await UserEquity.find({ owner: req.user._id }).lean();
|
||||
|
||||
const exportData = {
|
||||
exportAt: new Date().toISOString(),
|
||||
userId: req.user._id.toString(),
|
||||
version: '1.0',
|
||||
equities: equities
|
||||
};
|
||||
|
||||
const encrypted = encryptData(exportData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
encryptedData: encrypted,
|
||||
filename: `quanyi_backup_${new Date().getTime()}.json`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/import', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { encryptedData } = req.body;
|
||||
|
||||
if (!encryptedData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少加密数据'
|
||||
});
|
||||
}
|
||||
|
||||
const decrypted = decryptData(encryptedData);
|
||||
|
||||
if (!decrypted.equities || !Array.isArray(decrypted.equities)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '数据格式错误'
|
||||
});
|
||||
}
|
||||
|
||||
const UserEquity = require('../models/UserEquity');
|
||||
const User = require('../models/User');
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
const currentCount = await UserEquity.countDocuments({ owner: req.user._id });
|
||||
const importCount = decrypted.equities.length;
|
||||
|
||||
if (!user.isVip && (currentCount + importCount) > user.platformLimit) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: `导入后平台数量将超过限制(${user.platformLimit}个),请升级会员`
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
for (const item of decrypted.equities) {
|
||||
try {
|
||||
const equityData = {
|
||||
platform: item.platform,
|
||||
type: item.type,
|
||||
expireDate: item.expireDate,
|
||||
price: item.price || 0,
|
||||
benefits: item.benefits || [],
|
||||
owner: req.user._id,
|
||||
status: item.status || 'active',
|
||||
note: item.note || '',
|
||||
createTime: item.createTime || new Date().toISOString(),
|
||||
updateTime: new Date().toISOString(),
|
||||
syncedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const equity = await UserEquity.create(equityData);
|
||||
results.push(equity);
|
||||
} catch (itemError) {
|
||||
errors.push({ item, error: itemError.message });
|
||||
}
|
||||
}
|
||||
|
||||
user.platformCount = await UserEquity.countDocuments({ owner: req.user._id });
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
imported: results.length,
|
||||
failed: errors.length,
|
||||
items: results,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,268 @@
|
||||
const express = require('express');
|
||||
const UserSubscription = require('../models/UserSubscription');
|
||||
const UserEquity = require('../models/UserEquity');
|
||||
const WechatSubscribeService = require('../services/wechatSubscribeService');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/save', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { templateId, scene } = req.body;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'templateId 不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
|
||||
const existingSubscription = await UserSubscription.findOne({
|
||||
userId: user._id,
|
||||
templateId,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
return res.json({ success: true, message: '已存在有效的订阅' });
|
||||
}
|
||||
|
||||
const subscription = new UserSubscription({
|
||||
userId: user._id,
|
||||
openid: user.openid,
|
||||
templateId,
|
||||
scene: scene || ''
|
||||
});
|
||||
|
||||
await subscription.save();
|
||||
|
||||
res.json({ success: true, message: '订阅保存成功' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/version-update', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { templateId, character_string1, thing3, thing6 } = req.body;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'templateId 不能为空'
|
||||
});
|
||||
}
|
||||
if (!character_string1) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'character_string1 不能为空'
|
||||
});
|
||||
}
|
||||
if (!thing3) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'thing3 不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
|
||||
const subscription = await UserSubscription.findOne({
|
||||
userId: user._id,
|
||||
templateId,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '未找到有效的订阅,用户可能未订阅该模板'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await WechatSubscribeService.sendVersionUpdateMessage({
|
||||
openid: user.openid,
|
||||
templateId,
|
||||
character_string1,
|
||||
thing3,
|
||||
thing6
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
subscription.status = 'used';
|
||||
subscription.usedAt = new Date();
|
||||
await subscription.save();
|
||||
|
||||
res.json({ success: true, messageId: result.messageId, message: '消息发送成功' });
|
||||
} else {
|
||||
if (result.errcode === 43101 || result.errcode === 40037) {
|
||||
subscription.status = 'expired';
|
||||
subscription.expiredAt = new Date();
|
||||
await subscription.save();
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.errmsg || '消息发送失败',
|
||||
errcode: result.errcode
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/expiry-save', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { templateId } = req.body;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'templateId 不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
|
||||
const existingSubscription = await UserSubscription.findOne({
|
||||
userId: user._id,
|
||||
templateId,
|
||||
type: 'expiry-reminder',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
return res.json({ success: true, message: '已存在有效的到期提醒订阅' });
|
||||
}
|
||||
|
||||
const subscription = new UserSubscription({
|
||||
userId: user._id,
|
||||
openid: user.openid,
|
||||
templateId,
|
||||
type: 'expiry-reminder',
|
||||
scene: ''
|
||||
});
|
||||
|
||||
await subscription.save();
|
||||
|
||||
res.json({ success: true, message: '到期提醒订阅保存成功' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function sendExpiryReminders() {
|
||||
const now = new Date();
|
||||
const results = { total: 0, sent: 0, failed: 0, errors: [] };
|
||||
|
||||
const daysToCheck = [10, 3, 0];
|
||||
|
||||
for (const daysBefore of daysToCheck) {
|
||||
const targetDate = new Date(now);
|
||||
targetDate.setDate(targetDate.getDate() + daysBefore);
|
||||
const targetDateStr = targetDate.toISOString().split('T')[0];
|
||||
|
||||
const subscriptions = await UserSubscription.find({
|
||||
type: 'expiry-reminder',
|
||||
status: 'active',
|
||||
lastSentDay: { $ne: daysBefore }
|
||||
});
|
||||
|
||||
if (subscriptions.length === 0) continue;
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
const equities = await UserEquity.find({
|
||||
owner: sub.userId,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
for (const equity of equities) {
|
||||
const dayLabel = daysBefore === 0 ? '今天' : `${daysBefore}天`;
|
||||
let shouldSend = false;
|
||||
let reminderTarget = null;
|
||||
|
||||
if (equity.expireDate === targetDateStr) {
|
||||
shouldSend = true;
|
||||
reminderTarget = {
|
||||
name: `${equity.platform}${equity.type}`,
|
||||
type: 'platform'
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldSend && equity.benefits && equity.benefits.length > 0) {
|
||||
for (const benefit of equity.benefits) {
|
||||
if (benefit.expireDate === targetDateStr) {
|
||||
shouldSend = true;
|
||||
reminderTarget = {
|
||||
name: benefit.name,
|
||||
type: 'benefit'
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSend) continue;
|
||||
|
||||
let thing2;
|
||||
if (reminderTarget.type === 'platform') {
|
||||
thing2 = `您的${reminderTarget.name}将在${dayLabel}到期`;
|
||||
} else {
|
||||
thing2 = `您的${equity.platform}权益「${reminderTarget.name}」将在${dayLabel}到期`;
|
||||
}
|
||||
|
||||
const result = await WechatSubscribeService.sendExpiryReminderMessage({
|
||||
openid: sub.openid,
|
||||
templateId: sub.templateId,
|
||||
thing1: equity.platform,
|
||||
thing2: thing2,
|
||||
phrase3: daysBefore === 0 ? '已到期' : '即将到期'
|
||||
});
|
||||
|
||||
results.total++;
|
||||
|
||||
if (result.success) {
|
||||
results.sent++;
|
||||
sub.lastSentDay = daysBefore;
|
||||
await sub.save();
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.errcode === 43101 || result.errcode === 40037) {
|
||||
sub.status = 'expired';
|
||||
sub.expiredAt = new Date();
|
||||
await sub.save();
|
||||
}
|
||||
results.errors.push({
|
||||
openid: sub.openid,
|
||||
platform: equity.platform,
|
||||
error: result.errmsg
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (subError) {
|
||||
results.errors.push({
|
||||
openid: sub.openid,
|
||||
error: subError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
router.post('/send-expiry-reminders', async (req, res, next) => {
|
||||
try {
|
||||
const results = await sendExpiryReminders();
|
||||
res.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.sendExpiryReminders = sendExpiryReminders;
|
||||
@@ -0,0 +1,352 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const Trade = require('../models/Trade');
|
||||
const Equity = require('../models/Equity');
|
||||
|
||||
router.get('/', auth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
status,
|
||||
role,
|
||||
sortBy = 'createdAt',
|
||||
order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const query = {};
|
||||
|
||||
if (role === 'seller') {
|
||||
query.seller = req.user._id;
|
||||
} else if (role === 'buyer') {
|
||||
query.buyer = req.user._id;
|
||||
} else {
|
||||
query.$or = [
|
||||
{ seller: req.user._id },
|
||||
{ buyer: req.user._id }
|
||||
];
|
||||
}
|
||||
|
||||
if (status) query.status = status;
|
||||
|
||||
const sortOrder = order === 'asc' ? 1 : -1;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const [trades, total] = await Promise.all([
|
||||
Trade.find(query)
|
||||
.populate('equity', 'title type platform value')
|
||||
.populate('seller', 'nickname avatarUrl')
|
||||
.populate('buyer', 'nickname avatarUrl')
|
||||
.sort({ [sortBy]: sortOrder })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.lean(),
|
||||
Trade.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: trades,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/market', auth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
platform,
|
||||
type,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
sortBy = 'createdAt',
|
||||
order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const query = {
|
||||
status: 'pending',
|
||||
seller: { $ne: req.user._id }
|
||||
};
|
||||
|
||||
if (platform) query.platform = platform;
|
||||
if (type) query.type = type;
|
||||
if (minPrice || maxPrice) {
|
||||
query.price = {};
|
||||
if (minPrice) query.price.$gte = parseFloat(minPrice);
|
||||
if (maxPrice) query.price.$lte = parseFloat(maxPrice);
|
||||
}
|
||||
|
||||
const equityQuery = { isTransferable: true };
|
||||
if (platform) equityQuery.platform = platform;
|
||||
if (type) equityQuery.type = type;
|
||||
|
||||
const sortOrder = order === 'asc' ? 1 : -1;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const equities = await Equity.find(equityQuery)
|
||||
.populate('owner', 'nickname avatarUrl')
|
||||
.sort({ [sortBy]: sortOrder })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.lean();
|
||||
|
||||
const total = await Equity.countDocuments(equityQuery);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: equities,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', auth, async (req, res, next) => {
|
||||
try {
|
||||
const trade = await Trade.findById(req.params.id)
|
||||
.populate('equity')
|
||||
.populate('seller', 'nickname avatarUrl')
|
||||
.populate('buyer', 'nickname avatarUrl');
|
||||
|
||||
if (!trade) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '交易不存在'
|
||||
});
|
||||
}
|
||||
|
||||
const isParticipant =
|
||||
trade.seller._id.toString() === req.user._id.toString() ||
|
||||
(trade.buyer && trade.buyer._id.toString() === req.user._id.toString());
|
||||
|
||||
if (!isParticipant) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '无权查看此交易'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trade
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { equityId, price, description, tradeType } = req.body;
|
||||
|
||||
if (!equityId || !price) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必填字段'
|
||||
});
|
||||
}
|
||||
|
||||
const equity = await Equity.findOne({
|
||||
_id: equityId,
|
||||
owner: req.user._id,
|
||||
status: 'active',
|
||||
isTransferable: true
|
||||
});
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在或不可转让'
|
||||
});
|
||||
}
|
||||
|
||||
const trade = await Trade.create({
|
||||
equity: equityId,
|
||||
seller: req.user._id,
|
||||
price,
|
||||
originalPrice: equity.value,
|
||||
description,
|
||||
tradeType: tradeType || 'sale'
|
||||
});
|
||||
|
||||
await Trade.findById(trade._id)
|
||||
.populate('equity', 'title type platform value')
|
||||
.populate('seller', 'nickname avatarUrl');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: trade
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/purchase', auth, async (req, res, next) => {
|
||||
try {
|
||||
const trade = await Trade.findOne({
|
||||
_id: req.params.id,
|
||||
status: 'pending',
|
||||
seller: { $ne: req.user._id }
|
||||
});
|
||||
|
||||
if (!trade) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '交易不存在或不可购买'
|
||||
});
|
||||
}
|
||||
|
||||
trade.buyer = req.user._id;
|
||||
trade.status = 'paid';
|
||||
trade.paidAt = new Date();
|
||||
await trade.save();
|
||||
|
||||
await Equity.findByIdAndUpdate(trade.equity, {
|
||||
status: 'transferred',
|
||||
owner: req.user._id
|
||||
});
|
||||
|
||||
const updatedTrade = await Trade.findById(trade._id)
|
||||
.populate('equity')
|
||||
.populate('seller', 'nickname avatarUrl')
|
||||
.populate('buyer', 'nickname avatarUrl');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedTrade
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/cancel', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { reason } = req.body;
|
||||
|
||||
const trade = await Trade.findOne({
|
||||
_id: req.params.id,
|
||||
$or: [
|
||||
{ seller: req.user._id },
|
||||
{ buyer: req.user._id }
|
||||
],
|
||||
status: { $in: ['pending', 'paid'] }
|
||||
});
|
||||
|
||||
if (!trade) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '交易不存在或无法取消'
|
||||
});
|
||||
}
|
||||
|
||||
trade.status = 'cancelled';
|
||||
trade.cancelledAt = new Date();
|
||||
trade.cancelReason = reason || '用户取消';
|
||||
await trade.save();
|
||||
|
||||
if (trade.status === 'paid') {
|
||||
await Equity.findByIdAndUpdate(trade.equity, {
|
||||
status: 'active',
|
||||
owner: trade.seller
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '交易已取消',
|
||||
data: trade
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/complete', auth, async (req, res, next) => {
|
||||
try {
|
||||
const trade = await Trade.findOne({
|
||||
_id: req.params.id,
|
||||
buyer: req.user._id,
|
||||
status: 'paid'
|
||||
});
|
||||
|
||||
if (!trade) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '交易不存在或无法完成'
|
||||
});
|
||||
}
|
||||
|
||||
trade.status = 'completed';
|
||||
trade.completedAt = new Date();
|
||||
await trade.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '交易已完成',
|
||||
data: trade
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/rate', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { score, comment } = req.body;
|
||||
|
||||
if (!score || score < 1 || score > 5) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '评分必须在1-5之间'
|
||||
});
|
||||
}
|
||||
|
||||
const trade = await Trade.findOne({
|
||||
_id: req.params.id,
|
||||
buyer: req.user._id,
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
if (!trade) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '交易不存在或无法评价'
|
||||
});
|
||||
}
|
||||
|
||||
trade.rating = { score, comment };
|
||||
await trade.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '评价已提交',
|
||||
data: trade
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,63 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads/equity-notes');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname || '') || '.jpg';
|
||||
cb(null, 'note-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname || '').toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype || '');
|
||||
if (extname || mimetype) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只允许上传图片文件'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/image', auth, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '请选择要上传的图片'
|
||||
});
|
||||
}
|
||||
|
||||
const filename = req.file.filename;
|
||||
const baseUrl = process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn';
|
||||
const url = `${baseUrl}/uploads/equity-notes/${filename}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
url
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,240 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { auth } = require('../middleware/auth');
|
||||
const User = require('../models/User');
|
||||
const { downloadAndSaveAvatar } = require('../services/avatarService');
|
||||
const { compressAvatar } = require('../services/imageService');
|
||||
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads/avatars');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname || '') || '.jpg';
|
||||
cb(null, 'avatar-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname || '').toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype || '');
|
||||
if (extname || mimetype) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('只允许上传图片文件'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/profile', auth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await User.findById(req.user._id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '用户不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: user.userId || '',
|
||||
nickname: user.nickname || '',
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
status: user.status || 'active',
|
||||
isVip: user.isVip || false,
|
||||
vipExpireAt: user.vipExpireAt || null,
|
||||
ocrCount: user.ocrCount || 10,
|
||||
ocrCountTotal: user.ocrCountTotal || 10,
|
||||
platformLimit: user.platformLimit || 15,
|
||||
platformCount: user.platformCount || 0,
|
||||
lastLoginAt: user.lastLoginAt || null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/profile', auth, async (req, res, next) => {
|
||||
try {
|
||||
const allowedUpdates = ['nickname', 'avatarUrl', 'phoneNumber', 'profile'];
|
||||
const updates = {};
|
||||
|
||||
Object.keys(req.body).forEach(key => {
|
||||
if (allowedUpdates.includes(key)) {
|
||||
updates[key] = req.body[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (updates.avatarUrl && updates.avatarUrl.startsWith('http')) {
|
||||
const savedAvatarUrl = await downloadAndSaveAvatar(updates.avatarUrl);
|
||||
if (savedAvatarUrl) {
|
||||
updates.avatarUrl = savedAvatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
req.user._id,
|
||||
updates,
|
||||
{ new: true, runValidators: true }
|
||||
).select('-__v');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/avatar', auth, upload.single('avatar'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
console.error('[头像上传] 未收到文件,req.body keys:', Object.keys(req.body || {}));
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '没有上传文件'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[头像上传] 收到文件:', req.file.originalname, '大小:', req.file.size, '类型:', req.file.mimetype);
|
||||
console.log('[头像上传] 临时路径:', req.file.path);
|
||||
|
||||
const originalPath = req.file.path;
|
||||
try {
|
||||
await compressAvatar(originalPath);
|
||||
console.log('[头像上传] 压缩完成');
|
||||
} catch (compressError) {
|
||||
console.error('[头像上传] 压缩失败:', compressError.message);
|
||||
console.error('[头像上传] 压缩错误栈:', compressError.stack);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '图片处理失败,请重试'
|
||||
});
|
||||
}
|
||||
|
||||
let finalFilename = req.file.filename;
|
||||
const ext = path.extname(finalFilename).toLowerCase();
|
||||
if (ext !== '.jpg' && ext !== '.jpeg') {
|
||||
const newFilename = finalFilename.replace(ext, '.jpg');
|
||||
const newPath = path.join(uploadDir, newFilename);
|
||||
fs.renameSync(originalPath, newPath);
|
||||
finalFilename = newFilename;
|
||||
console.log('[头像上传] 文件名从', req.file.filename, '改为', newFilename);
|
||||
}
|
||||
|
||||
const avatarUrl = `${process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn'}/uploads/avatars/${finalFilename}`;
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
req.user._id,
|
||||
{ avatarUrl },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
avatarUrl: user.avatarUrl,
|
||||
url: avatarUrl
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stats', auth, async (req, res, next) => {
|
||||
try {
|
||||
const Equity = require('../models/Equity');
|
||||
const Trade = require('../models/Trade');
|
||||
|
||||
const [totalEquities, activeEquities, totalTrades, sellingTrades] = await Promise.all([
|
||||
Equity.countDocuments({ owner: req.user._id }),
|
||||
Equity.countDocuments({ owner: req.user._id, status: 'active' }),
|
||||
Trade.countDocuments({ $or: [{ seller: req.user._id }, { buyer: req.user._id }] }),
|
||||
Trade.countDocuments({ seller: req.user._id, status: 'pending' })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalEquities,
|
||||
activeEquities,
|
||||
totalTrades,
|
||||
sellingTrades
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/growth-stats', auth, async (req, res, next) => {
|
||||
try {
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
|
||||
const dailyCounts = await User.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdAt: { $gte: thirtyDaysAgo }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$createdAt', timezone: 'Asia/Shanghai' }
|
||||
},
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
},
|
||||
{ $sort: { _id: 1 } }
|
||||
]);
|
||||
|
||||
const countMap = {};
|
||||
dailyCounts.forEach(item => {
|
||||
countMap[item._id] = item.count;
|
||||
});
|
||||
|
||||
const list = [];
|
||||
let cumulative = 0;
|
||||
|
||||
for (let i = 0; i <= 30; i++) {
|
||||
const d = new Date(thirtyDaysAgo);
|
||||
d.setDate(d.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const month = d.getMonth() + 1;
|
||||
const day = d.getDate();
|
||||
|
||||
cumulative += countMap[dateStr] || 0;
|
||||
list.push({
|
||||
date: `${month}月${day}日`,
|
||||
userCount: cumulative
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { list } });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,447 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { auth } = require('../middleware/auth');
|
||||
const UserEquity = require('../models/UserEquity');
|
||||
const User = require('../models/User');
|
||||
|
||||
async function updateUserPlatformCount(userId) {
|
||||
const count = await UserEquity.countDocuments({ owner: userId });
|
||||
await User.findByIdAndUpdate(userId, { platformCount: count });
|
||||
}
|
||||
|
||||
const requireVip = (req, res, next) => {
|
||||
if (!req.user.isVip) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: '非VIP用户无法使用云端同步功能,请升级会员'
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
router.get('/', auth, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 100,
|
||||
status,
|
||||
platform,
|
||||
sortBy = 'createTime',
|
||||
order = 'desc'
|
||||
} = req.query;
|
||||
|
||||
const query = { owner: req.user._id };
|
||||
|
||||
if (status) query.status = status;
|
||||
if (platform) query.platform = platform;
|
||||
|
||||
const sortOrder = order === 'asc' ? 1 : -1;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const [equities, total] = await Promise.all([
|
||||
UserEquity.find(query)
|
||||
.sort({ [sortBy]: sortOrder })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.lean(),
|
||||
UserEquity.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: equities,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/all', auth, async (req, res, next) => {
|
||||
try {
|
||||
const { since } = req.query;
|
||||
const query = { owner: req.user._id };
|
||||
|
||||
if (since) {
|
||||
query.updateTime = { $gt: since };
|
||||
}
|
||||
|
||||
const equities = await UserEquity.find(query)
|
||||
.sort({ updateTime: -1 })
|
||||
.lean();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: equities,
|
||||
count: equities.length
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', auth, async (req, res, next) => {
|
||||
try {
|
||||
const equities = await UserEquity.find({ owner: req.user._id })
|
||||
.sort({ createTime: -1 })
|
||||
.lean();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: equities,
|
||||
count: equities.length
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', auth, async (req, res, next) => {
|
||||
try {
|
||||
const equity = await UserEquity.findOne({
|
||||
_id: req.params.id,
|
||||
owner: req.user._id
|
||||
});
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', auth, requireVip, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
platform,
|
||||
type,
|
||||
expireDate,
|
||||
price,
|
||||
benefits,
|
||||
status,
|
||||
note,
|
||||
createTime,
|
||||
updateTime,
|
||||
platformType,
|
||||
brandIcon,
|
||||
brandIconImage,
|
||||
brandColor,
|
||||
hasUsedBenefit
|
||||
} = req.body;
|
||||
|
||||
if (!platform || !type || !expireDate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必填字段:平台名称、会员类型、到期时间'
|
||||
});
|
||||
}
|
||||
|
||||
const equityData = {
|
||||
platform: platform.trim(),
|
||||
type: type.trim(),
|
||||
expireDate,
|
||||
price: parseFloat(price) || 0,
|
||||
benefits: benefits || [],
|
||||
owner: req.user._id,
|
||||
status: status || 'active',
|
||||
note: {
|
||||
text: (note && note.text) || '',
|
||||
images: (note && note.images) || []
|
||||
},
|
||||
syncedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (platformType !== undefined) equityData.platformType = platformType;
|
||||
if (brandIcon !== undefined) equityData.brandIcon = brandIcon;
|
||||
if (brandIconImage !== undefined) equityData.brandIconImage = brandIconImage;
|
||||
if (brandColor !== undefined) equityData.brandColor = brandColor;
|
||||
if (hasUsedBenefit !== undefined) equityData.hasUsedBenefit = hasUsedBenefit;
|
||||
if (createTime) equityData.createTime = createTime;
|
||||
if (updateTime) equityData.updateTime = updateTime;
|
||||
|
||||
const equity = await UserEquity.create(equityData);
|
||||
await updateUserPlatformCount(req.user._id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/batch', auth, requireVip, async (req, res, next) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少数据或格式错误'
|
||||
});
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const {
|
||||
platform,
|
||||
type,
|
||||
expireDate,
|
||||
price,
|
||||
benefits,
|
||||
status,
|
||||
note,
|
||||
createTime,
|
||||
updateTime,
|
||||
platformType,
|
||||
brandIcon,
|
||||
brandIconImage,
|
||||
brandColor,
|
||||
hasUsedBenefit
|
||||
} = item;
|
||||
|
||||
if (!platform || !type || !expireDate) {
|
||||
errors.push({ item, error: '缺少必填字段' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const equityData = {
|
||||
platform: platform.trim(),
|
||||
type: type.trim(),
|
||||
expireDate,
|
||||
price: parseFloat(price) || 0,
|
||||
benefits: benefits || [],
|
||||
owner: req.user._id,
|
||||
status: status || 'active',
|
||||
note: {
|
||||
text: (note && note.text) || '',
|
||||
images: (note && note.images) || []
|
||||
},
|
||||
syncedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (platformType !== undefined) equityData.platformType = platformType;
|
||||
if (brandIcon !== undefined) equityData.brandIcon = brandIcon;
|
||||
if (brandIconImage !== undefined) equityData.brandIconImage = brandIconImage;
|
||||
if (brandColor !== undefined) equityData.brandColor = brandColor;
|
||||
if (hasUsedBenefit !== undefined) equityData.hasUsedBenefit = hasUsedBenefit;
|
||||
if (createTime) equityData.createTime = createTime;
|
||||
if (updateTime) equityData.updateTime = updateTime;
|
||||
|
||||
const equity = await UserEquity.create(equityData);
|
||||
results.push(equity);
|
||||
} catch (itemError) {
|
||||
errors.push({ item, error: itemError.message });
|
||||
}
|
||||
}
|
||||
|
||||
await updateUserPlatformCount(req.user._id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
created: results.length,
|
||||
failed: errors.length,
|
||||
items: results,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', auth, requireVip, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
platform,
|
||||
type,
|
||||
expireDate,
|
||||
price,
|
||||
benefits,
|
||||
status,
|
||||
note,
|
||||
createTime,
|
||||
updateTime,
|
||||
platformType,
|
||||
brandIcon,
|
||||
brandIconImage,
|
||||
brandColor,
|
||||
hasUsedBenefit
|
||||
} = req.body;
|
||||
|
||||
const updates = {};
|
||||
|
||||
if (platform !== undefined) updates.platform = platform.trim();
|
||||
if (type !== undefined) updates.type = type.trim();
|
||||
if (expireDate !== undefined) updates.expireDate = expireDate;
|
||||
if (price !== undefined) updates.price = parseFloat(price) || 0;
|
||||
if (benefits !== undefined) updates.benefits = benefits;
|
||||
if (status !== undefined) updates.status = status;
|
||||
if (note !== undefined) updates.note = {
|
||||
text: (note && note.text) || '',
|
||||
images: (note && note.images) || []
|
||||
};
|
||||
if (platformType !== undefined) updates.platformType = platformType;
|
||||
if (brandIcon !== undefined) updates.brandIcon = brandIcon;
|
||||
if (brandIconImage !== undefined) updates.brandIconImage = brandIconImage;
|
||||
if (brandColor !== undefined) updates.brandColor = brandColor;
|
||||
if (hasUsedBenefit !== undefined) updates.hasUsedBenefit = hasUsedBenefit;
|
||||
if (createTime !== undefined) updates.createTime = createTime;
|
||||
if (updateTime !== undefined) updates.updateTime = updateTime;
|
||||
|
||||
updates.syncedAt = new Date().toISOString();
|
||||
|
||||
const equity = await UserEquity.findOneAndUpdate(
|
||||
{ _id: req.params.id, owner: req.user._id },
|
||||
updates,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在或无权限修改'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id/benefits', auth, requireVip, async (req, res, next) => {
|
||||
try {
|
||||
const { benefit, action } = req.body;
|
||||
|
||||
const equity = await UserEquity.findOne({
|
||||
_id: req.params.id,
|
||||
owner: req.user._id
|
||||
});
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在或无权限修改'
|
||||
});
|
||||
}
|
||||
|
||||
const benefits = equity.benefits || [];
|
||||
|
||||
if (action === 'use') {
|
||||
const benefitIndex = benefits.findIndex(b => b.name === benefit.benefitName);
|
||||
if (benefitIndex === -1) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益项不存在'
|
||||
});
|
||||
}
|
||||
benefits[benefitIndex].used = true;
|
||||
benefits[benefitIndex].usedTime = new Date().toISOString();
|
||||
} else if (action === 'delete') {
|
||||
const benefitIndex = benefits.findIndex(b => b.name === benefit.benefitName);
|
||||
if (benefitIndex === -1) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益项不存在'
|
||||
});
|
||||
}
|
||||
benefits.splice(benefitIndex, 1);
|
||||
} else if (action === 'update') {
|
||||
const benefitIndex = benefits.findIndex(b => b.name === benefit.oldBenefitName);
|
||||
if (benefitIndex === -1) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益项不存在'
|
||||
});
|
||||
}
|
||||
benefits[benefitIndex] = {
|
||||
...benefits[benefitIndex].toObject(),
|
||||
...benefit.newBenefitData,
|
||||
updateTime: new Date().toISOString()
|
||||
};
|
||||
} else {
|
||||
if (!benefit || !benefit.name || !benefit.type) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必填字段:权益名称、权益类型'
|
||||
});
|
||||
}
|
||||
benefits.push({
|
||||
name: benefit.name.trim(),
|
||||
type: benefit.type,
|
||||
typeLabel: benefit.typeLabel || '',
|
||||
expireDate: benefit.expireDate || null,
|
||||
used: false,
|
||||
usedTime: null
|
||||
});
|
||||
}
|
||||
|
||||
equity.benefits = benefits;
|
||||
equity.updateTime = new Date().toISOString();
|
||||
equity.syncedAt = new Date().toISOString();
|
||||
await equity.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: equity
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', auth, requireVip, async (req, res, next) => {
|
||||
try {
|
||||
const equity = await UserEquity.findOneAndDelete({
|
||||
_id: req.params.id,
|
||||
owner: req.user._id
|
||||
});
|
||||
|
||||
if (!equity) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '权益不存在或无权限删除'
|
||||
});
|
||||
}
|
||||
|
||||
await updateUserPlatformCount(req.user._id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '权益已删除'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const router = express.Router();
|
||||
|
||||
const WECHAT_TOKEN = 'QuanYiXiaoZhuShou2026';
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const { signature, timestamp, nonce, echostr } = req.query;
|
||||
|
||||
const sortedArr = [WECHAT_TOKEN, timestamp, nonce].sort();
|
||||
const tmpStr = sortedArr.join('');
|
||||
|
||||
const hashedStr = crypto.createHash('sha1').update(tmpStr).digest('hex');
|
||||
|
||||
if (hashedStr === signature) {
|
||||
res.send(echostr);
|
||||
} else {
|
||||
res.status(401).send('验证失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
res.send('success');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,47 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const AVATAR_DIR = path.join(process.cwd(), 'public', 'avatars');
|
||||
|
||||
if (!fs.existsSync(AVATAR_DIR)) {
|
||||
fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function downloadAndSaveAvatar(avatarUrl) {
|
||||
if (!avatarUrl || !avatarUrl.startsWith('http')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(avatarUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
const filename = `${crypto.randomUUID()}.jpg`;
|
||||
const filepath = path.join(AVATAR_DIR, filename);
|
||||
|
||||
const compressed = await sharp(response.data)
|
||||
.resize(400, 400, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 80, mozjpeg: true })
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(filepath, compressed);
|
||||
|
||||
const serverUrl = process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn';
|
||||
return `${serverUrl}/avatars/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('下载头像失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadAndSaveAvatar
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const AVATAR_MAX_SIZE = 400;
|
||||
const AVATAR_QUALITY = 80;
|
||||
|
||||
async function compressAvatar(inputPath, outputPath) {
|
||||
try {
|
||||
const exists = fs.existsSync(inputPath);
|
||||
const stats = exists ? fs.statSync(inputPath) : null;
|
||||
console.log('[图像压缩] 输入文件:', inputPath, '存在:', exists, '大小:', stats?.size, '字节');
|
||||
|
||||
const metadata = await sharp(inputPath).metadata();
|
||||
console.log('[图像压缩] 原图尺寸:', metadata.width, 'x', metadata.height, '格式:', metadata.format);
|
||||
|
||||
if (metadata.width <= AVATAR_MAX_SIZE && metadata.height <= AVATAR_MAX_SIZE && metadata.format === 'jpeg') {
|
||||
console.log('[图像压缩] 跳过压缩(已满足要求)');
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
const finalOutputPath = outputPath || inputPath;
|
||||
|
||||
if (finalOutputPath === inputPath) {
|
||||
const tempPath = inputPath + '.tmp';
|
||||
await sharp(inputPath)
|
||||
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||
.toFile(tempPath);
|
||||
|
||||
fs.unlinkSync(inputPath);
|
||||
fs.renameSync(tempPath, inputPath);
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
await sharp(inputPath)
|
||||
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||
.toFile(finalOutputPath);
|
||||
|
||||
return finalOutputPath;
|
||||
} catch (error) {
|
||||
console.error('图像压缩失败:', error.message);
|
||||
return inputPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function compressAvatarBuffer(buffer) {
|
||||
try {
|
||||
const compressed = await sharp(buffer)
|
||||
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||
.toBuffer();
|
||||
|
||||
return compressed;
|
||||
} catch (error) {
|
||||
console.error('图像缓冲区压缩失败:', error.message);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compressAvatar,
|
||||
compressAvatarBuffer
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const BAIDU_OCR_API = {
|
||||
token: 'https://aip.baidubce.com/oauth/2.0/token',
|
||||
generalBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic',
|
||||
accurateBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic'
|
||||
};
|
||||
|
||||
let accessTokenCache = {
|
||||
token: null,
|
||||
expireAt: 0
|
||||
};
|
||||
|
||||
async function getAccessToken() {
|
||||
const now = Date.now();
|
||||
if (accessTokenCache.token && accessTokenCache.expireAt > now + 60000) {
|
||||
return accessTokenCache.token;
|
||||
}
|
||||
|
||||
const apiKey = process.env.BAIDU_OCR_API_KEY;
|
||||
const secretKey = process.env.BAIDU_OCR_SECRET_KEY;
|
||||
|
||||
if (!apiKey || !secretKey) {
|
||||
throw new Error('百度OCR配置缺失:请检查 BAIDU_OCR_API_KEY 和 BAIDU_OCR_SECRET_KEY');
|
||||
}
|
||||
|
||||
const response = await axios.post(BAIDU_OCR_API.token, null, {
|
||||
params: {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: apiKey,
|
||||
client_secret: secretKey
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const { access_token, expires_in } = response.data;
|
||||
|
||||
if (!access_token) {
|
||||
throw new Error(`获取百度OCR Token失败: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
accessTokenCache = {
|
||||
token: access_token,
|
||||
expireAt: now + (expires_in * 1000)
|
||||
};
|
||||
|
||||
return access_token;
|
||||
}
|
||||
|
||||
async function recognizeText(imageBase64, options = {}) {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const url = `${BAIDU_OCR_API.generalBasic}?access_token=${accessToken}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('image', imageBase64);
|
||||
|
||||
if (options.language_type) {
|
||||
params.append('language_type', options.language_type);
|
||||
}
|
||||
|
||||
const response = await axios.post(url, params.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function recognizeTextAccurate(imageBase64, options = {}) {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const url = `${BAIDU_OCR_API.accurateBasic}?access_token=${accessToken}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('image', imageBase64);
|
||||
|
||||
if (options.language_type) {
|
||||
params.append('language_type', options.language_type);
|
||||
}
|
||||
|
||||
const response = await axios.post(url, params.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function getDefaultPrice(type, times) {
|
||||
const prices = {
|
||||
year: '120',
|
||||
halfYear: '60',
|
||||
quarter: '30',
|
||||
month: '10'
|
||||
};
|
||||
|
||||
if (type === 'times') {
|
||||
const timesCount = parseInt(times) || 1;
|
||||
return String(timesCount * 10);
|
||||
}
|
||||
|
||||
return prices[type] || '10';
|
||||
}
|
||||
|
||||
function extractMembershipInfo(ocrResult) {
|
||||
if (!ocrResult || !ocrResult.words_result || ocrResult.words_result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const text = ocrResult.words_result.map(w => w.words).join('\n');
|
||||
const lines = ocrResult.words_result.map(w => w.words);
|
||||
|
||||
const platformKeywords = {
|
||||
'淘宝': ['淘宝', 'taobao', '88vip', '88VIP'],
|
||||
'京东': ['京东', 'jd', 'JD', 'plus', 'PLUS'],
|
||||
'拼多多': ['拼多多', 'pdd', 'PDD'],
|
||||
'美团': ['美团', 'meituan'],
|
||||
'饿了么': ['饿了么', 'eleme', 'ele.me'],
|
||||
'抖音': ['抖音', 'douyin', 'tiktok'],
|
||||
'快手': ['快手', 'kuaishou'],
|
||||
'网易云音乐': ['网易云', 'netease', '163'],
|
||||
'QQ音乐': ['QQ音乐', 'qq音乐'],
|
||||
'优酷': ['优酷', 'youku'],
|
||||
'爱奇艺': ['爱奇艺', 'iqiyi'],
|
||||
'腾讯视频': ['腾讯视频', 'v.qq'],
|
||||
'哔哩哔哩': ['哔哩哔哩', 'bilibili', 'B站'],
|
||||
'喜马拉雅': ['喜马拉雅', 'ximalaya'],
|
||||
'知乎': ['知乎', 'zhihu'],
|
||||
'百度网盘': ['百度网盘', '百度云']
|
||||
};
|
||||
|
||||
let platform = null;
|
||||
for (const [pName, keywords] of Object.entries(platformKeywords)) {
|
||||
for (const keyword of keywords) {
|
||||
if (text.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
platform = pName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (platform) break;
|
||||
}
|
||||
|
||||
const typePatterns = [
|
||||
{ patterns: [/年卡/, /年度会员/, /\d+年/], type: 'year' },
|
||||
{ patterns: [/半年卡/, /半年会员/, /6个月/], type: 'halfYear' },
|
||||
{ patterns: [/季卡/, /季度会员/, /3个月/], type: 'quarter' },
|
||||
{ patterns: [/月卡/, /月度会员/, /1个月/], type: 'month' },
|
||||
{ patterns: [/次卡/, /按次数/], type: 'times' }
|
||||
];
|
||||
|
||||
let detectedType = 'month';
|
||||
for (const { patterns, type } of typePatterns) {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(text)) {
|
||||
detectedType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (detectedType !== 'month') break;
|
||||
}
|
||||
|
||||
const datePatterns = [
|
||||
/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/(\d{4})(\d{2})(\d{2})/,
|
||||
/(\d{2})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/有效期[至到::]\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/到期[时间日]:?\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/(\d{4})\.(\d{1,2})\.(\d{1,2})/
|
||||
];
|
||||
|
||||
let expireDate = '9999-12-31';
|
||||
for (const pattern of datePatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
let year = match[1];
|
||||
const month = match[2].padStart(2, '0');
|
||||
const day = match[3].padStart(2, '0');
|
||||
|
||||
if (year.length === 2) {
|
||||
year = '20' + year;
|
||||
}
|
||||
|
||||
expireDate = `${year}-${month}-${day}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const benefitKeywords = [
|
||||
'优酷', '网易云', 'QQ音乐', '酷狗', '酷我',
|
||||
'爱奇艺', '腾讯视频', '芒果TV', '哔哩哔哩',
|
||||
'饿了么', '美团', '高德打车', '滴滴',
|
||||
'夸克', '百度网盘', '迅雷',
|
||||
'喜马拉雅', '知乎', '微博',
|
||||
'淘票票', '飞猪', '希尔顿', '万豪',
|
||||
'视频会员', '超级吃货卡', '天猫超市', '天猫国际',
|
||||
'阿里健康', '专属客服', '省钱卡', '网盘会员',
|
||||
'打车会员', '金卡', '皮肤装扮', '每日领券',
|
||||
'出行礼遇', '专享立减', '游戏特权'
|
||||
];
|
||||
|
||||
const benefits = [];
|
||||
for (const line of lines) {
|
||||
for (const keyword of benefitKeywords) {
|
||||
if (line.includes(keyword)) {
|
||||
const existing = benefits.find(b => b.name === keyword);
|
||||
if (!existing) {
|
||||
benefits.push({
|
||||
name: keyword,
|
||||
type: detectedType,
|
||||
times: detectedType === 'times' ? null : null,
|
||||
price: getDefaultPrice(detectedType, null),
|
||||
expireDate: expireDate
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (benefits.length === 0 && platform) {
|
||||
benefits.push({
|
||||
name: platform,
|
||||
type: detectedType,
|
||||
times: null,
|
||||
price: getDefaultPrice(detectedType, null),
|
||||
expireDate: expireDate
|
||||
});
|
||||
}
|
||||
|
||||
return benefits;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccessToken,
|
||||
recognizeText,
|
||||
recognizeTextAccurate,
|
||||
extractMembershipInfo
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
const axios = require('axios');
|
||||
|
||||
let accessTokenCache = {
|
||||
token: null,
|
||||
expiresAt: null
|
||||
};
|
||||
|
||||
class WechatSubscribeService {
|
||||
static async getAccessToken() {
|
||||
const now = Date.now();
|
||||
|
||||
if (accessTokenCache.token && accessTokenCache.expiresAt && now < accessTokenCache.expiresAt) {
|
||||
return accessTokenCache.token;
|
||||
}
|
||||
|
||||
const appId = process.env.WECHAT_APPID;
|
||||
const appSecret = process.env.WECHAT_APPSECRET;
|
||||
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(url);
|
||||
const { access_token, expires_in } = response.data;
|
||||
|
||||
accessTokenCache = {
|
||||
token: access_token,
|
||||
expiresAt: now + (expires_in - 300) * 1000
|
||||
};
|
||||
|
||||
return access_token;
|
||||
} catch (error) {
|
||||
console.error('获取微信 access_token 失败:', error.response?.data || error.message);
|
||||
throw new Error('获取微信 access_token 失败');
|
||||
}
|
||||
}
|
||||
|
||||
static async sendSubscribeMessage({ touser, templateId, page, data }) {
|
||||
const accessToken = await this.getAccessToken();
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`;
|
||||
|
||||
const requestData = {
|
||||
touser,
|
||||
template_id: templateId,
|
||||
page: page || 'pages/index/index',
|
||||
data
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, requestData);
|
||||
|
||||
if (response.data.errcode === 0) {
|
||||
return { success: true, messageId: response.data.msgid };
|
||||
} else {
|
||||
console.error('发送订阅消息失败:', response.data);
|
||||
return {
|
||||
success: false,
|
||||
errcode: response.data.errcode,
|
||||
errmsg: response.data.errmsg
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送订阅消息异常:', error.response?.data || error.message);
|
||||
return { success: false, errmsg: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
static async sendVersionUpdateMessage({ openid, templateId, character_string1, thing3, thing6 }) {
|
||||
const data = {
|
||||
character_string1: { value: character_string1 },
|
||||
thing3: { value: thing3 }
|
||||
};
|
||||
|
||||
if (thing6) {
|
||||
data.thing6 = { value: thing6 };
|
||||
}
|
||||
|
||||
return this.sendSubscribeMessage({
|
||||
touser: openid,
|
||||
templateId,
|
||||
page: 'pages/index/index',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
static async sendExpiryReminderMessage({ openid, templateId, thing1, thing2, phrase3 }) {
|
||||
const data = {
|
||||
thing1: { value: thing1 },
|
||||
thing2: { value: thing2 },
|
||||
phrase3: { value: phrase3 }
|
||||
};
|
||||
|
||||
return this.sendSubscribeMessage({
|
||||
touser: openid,
|
||||
templateId,
|
||||
page: 'pages/profile/profile',
|
||||
data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WechatSubscribeService;
|
||||
@@ -0,0 +1,45 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title 权益小助手 - 一键启动脚本
|
||||
|
||||
echo ========================================
|
||||
echo 权益小助手 - 一键启动脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set MONGO_HOME=D:\001_software\012_MongoDB\mongodb-win32-x86_64-windows-8.0.16
|
||||
set DATA_DIR=D:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend\mongodb\data
|
||||
set LOG_DIR=D:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend\mongodb\log
|
||||
set BACKEND_DIR=D:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend
|
||||
|
||||
if not exist "%MONGO_HOME%\bin\mongod.exe" (
|
||||
echo [错误] 找不到 mongod.exe,请检查 MONGO_HOME 路径
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
echo [信息] 正在启动 MongoDB 服务...
|
||||
start "MongoDB - 权益小助手" cmd /k "echo [MongoDB] 正在运行... && \"%MONGO_HOME%\bin\mongod.exe\" --dbpath \"%DATA_DIR%\" --port 27017 --bind_ip 127.0.0.1"
|
||||
|
||||
echo [信息] 等待 MongoDB 初始化...
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
echo [信息] 正在启动后端服务...
|
||||
cd /d "%BACKEND_DIR%"
|
||||
start "后端服务 - 权益小助手" cmd /k "npm start"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo [成功] 所有服务已启动!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 服务状态:
|
||||
echo - MongoDB: 127.0.0.1:27017
|
||||
echo - 后端API: http://localhost:3000
|
||||
echo - 健康检查: http://localhost:3000/health
|
||||
echo.
|
||||
echo 按任意键关闭此窗口(服务将继续在后台运行)
|
||||
pause >nul
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title MongoDB 启动脚本 - 权益小助手
|
||||
|
||||
echo ========================================
|
||||
echo 权益小助手 - MongoDB 启动脚本
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
set MONGO_HOME=D:\001_software\012_MongoDB\mongodb-win32-x86_64-windows-8.0.16
|
||||
set DATA_DIR=D:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend\mongodb\data
|
||||
set LOG_DIR=D:\003_Project\WeixinProject\QuanYiXiaoZhuShou\backend\mongodb\log
|
||||
|
||||
if not exist "%MONGO_HOME%\bin\mongod.exe" (
|
||||
echo [错误] 找不到 mongod.exe,请检查 MONGO_HOME 路径
|
||||
echo 当前路径: %MONGO_HOME%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist "%DATA_DIR%" mkdir "%DATA_DIR%"
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
echo [信息] MongoDB 路径: %MONGO_HOME%
|
||||
echo [信息] 数据目录: %DATA_DIR%
|
||||
echo [信息] 日志目录: %LOG_DIR%
|
||||
echo.
|
||||
|
||||
echo [信息] 正在启动 MongoDB 服务...
|
||||
echo [信息] 按 Ctrl+C 可以停止服务
|
||||
echo.
|
||||
|
||||
"%MONGO_HOME%\bin\mongod.exe" --dbpath "%DATA_DIR%" --port 27017 --bind_ip 127.0.0.1
|
||||
|
||||
echo.
|
||||
echo [信息] MongoDB 服务已停止
|
||||
echo.
|
||||
pause
|
||||
@@ -1,221 +0,0 @@
|
||||
---
|
||||
name: "bugpack-deploy"
|
||||
description: "Deploy and configure BugPack (bug screenshot to AI instructions tool) on Windows with MSYS2. Invoke when user asks to setup/install/deploy BugPack or fix its build/runtime issues."
|
||||
---
|
||||
|
||||
# BugPack Deploy Skill
|
||||
|
||||
This skill guides the deployment of [BugPack](https://github.com/duhuazhu/BugPack) on Windows using MSYS2 environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Windows OS
|
||||
- MSYS2 installed (usually at `C:\msys64` or `D:\msys64`)
|
||||
- Internet connection for downloading packages
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### 1. MSYS2 Configuration
|
||||
|
||||
Open MSYS2 terminal and update the system:
|
||||
|
||||
```bash
|
||||
pacman -Syu
|
||||
# If prompted to close terminal, close and reopen, then run:
|
||||
pacman -Su
|
||||
```
|
||||
|
||||
### 2. Install Required Tools
|
||||
|
||||
```bash
|
||||
pacman -S --noconfirm git curl make
|
||||
pacman -S --noconfirm mingw-w64-x86_64-nodejs
|
||||
pacman -S --noconfirm mingw-w64-x86_64-gcc
|
||||
```
|
||||
|
||||
### 3. Node.js Environment Variables
|
||||
|
||||
Add to `~/.bashrc` or export manually:
|
||||
|
||||
```bash
|
||||
export PATH="/mingw64/bin:$PATH"
|
||||
export LD_LIBRARY_PATH="/mingw64/bin:$LD_LIBRARY_PATH"
|
||||
```
|
||||
|
||||
**Note**: `LD_LIBRARY_PATH` is required because `libnode.dll` is in `/mingw64/bin`.
|
||||
|
||||
### 4. Create Node.js Symlinks (Optional but recommended)
|
||||
|
||||
```bash
|
||||
ln -sf /mingw64/bin/node.exe /usr/bin/node
|
||||
ln -sf /mingw64/lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm
|
||||
ln -sf /mingw64/lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Download BugPack
|
||||
|
||||
```bash
|
||||
# Option A: Clone with git
|
||||
git clone https://github.com/duhuazhu/BugPack.git
|
||||
cd BugPack
|
||||
|
||||
# Option B: Download and extract ZIP
|
||||
curl -L -o BugPack.zip https://github.com/duhuazhu/BugPack/archive/refs/heads/main.zip
|
||||
unzip BugPack.zip
|
||||
cd BugPack-main
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Build Project
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 4. Fix Static File Path (Production Mode)
|
||||
|
||||
**Issue**: Server looks for `src/client/index.html` but built files are in `dist/client/`.
|
||||
|
||||
**Fix**: Edit `src/server/index.ts`:
|
||||
|
||||
```typescript
|
||||
// Before (line ~47):
|
||||
const clientDir = path.resolve(__dirname, '../client')
|
||||
|
||||
// After:
|
||||
const clientDir = path.resolve(__dirname, '../../dist/client')
|
||||
const devClientDir = path.resolve(__dirname, '../client')
|
||||
const staticDir = fs.existsSync(clientDir) ? clientDir : devClientDir
|
||||
|
||||
if (fs.existsSync(staticDir)) {
|
||||
app.use(express.static(staticDir))
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
|
||||
res.sendFile(path.join(staticDir, 'index.html'))
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Start Server
|
||||
|
||||
```bash
|
||||
# Development mode (with auto-reload)
|
||||
npm run dev:server
|
||||
|
||||
# Or specify custom port
|
||||
PORT=3458 npm run dev:server
|
||||
```
|
||||
|
||||
Server will start at `http://localhost:3457` (or your custom port).
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue 1: `node: command not found`
|
||||
|
||||
**Cause**: Node.js installed in `/mingw64/bin` but not in PATH.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
export PATH="/mingw64/bin:$PATH"
|
||||
```
|
||||
|
||||
### Issue 2: `libnode.dll: cannot open shared object file`
|
||||
|
||||
**Cause**: DLL not found in library path.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
export LD_LIBRARY_PATH="/mingw64/bin:$LD_LIBRARY_PATH"
|
||||
```
|
||||
|
||||
### Issue 3: `better-sqlite3` build fails with `make: cc: No such file`
|
||||
|
||||
**Cause**: C compiler not installed.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
pacman -S --noconfirm mingw-w64-x86_64-gcc make
|
||||
```
|
||||
|
||||
### Issue 4: `ENOENT: no such file or directory, stat '.../src/client/index.html'`
|
||||
|
||||
**Cause**: Server configured for dev mode but running production build.
|
||||
|
||||
**Solution**: Apply the static file path fix in Step 4 above.
|
||||
|
||||
### Issue 5: `EADDRINUSE: address already in use :::3457`
|
||||
|
||||
**Cause**: Another instance is running.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Use different port
|
||||
PORT=3458 npm run dev:server
|
||||
```
|
||||
|
||||
### Issue 6: MSYS2 mirror network issues
|
||||
|
||||
**Symptom**: All mirrors return "Could not resolve host".
|
||||
|
||||
**Solution**: Network environment issue. Try:
|
||||
1. Check internet connection
|
||||
2. Switch to different network
|
||||
3. Or configure specific mirror:
|
||||
```bash
|
||||
cat > /etc/pacman.d/mirrorlist.mingw64 << 'EOF'
|
||||
Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/mingw/x86_64/
|
||||
Server = https://mirrors.ustc.edu.cn/msys2/mingw/x86_64/
|
||||
EOF
|
||||
pacman -Syy
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After deployment, verify:
|
||||
|
||||
```bash
|
||||
# Check server is running
|
||||
curl -s http://localhost:3457 | head -5
|
||||
|
||||
# Expected output: HTML content starting with <!DOCTYPE html>
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
To integrate with Trae/Cursor/Claude Code, add MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Frontend | React 18 + TypeScript + Tailwind CSS |
|
||||
| Backend | Express + Node.js |
|
||||
| Database | SQLite (better-sqlite3) |
|
||||
| Annotation | Fabric.js v6 |
|
||||
| Build Tool | Vite |
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data stored locally:
|
||||
- **Data directory**: `~/.bugpack/data/`
|
||||
- **Database**: `bugpack.db`
|
||||
- **Screenshots**: `uploads/{ProjectName}/{uuid}.{ext}`
|
||||
-1
@@ -1 +0,0 @@
|
||||
ko_fi: W7W51W5EN5
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Bug Report
|
||||
description: 报告一个 Bug / Report a bug
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述 / Description
|
||||
description: 请清楚描述遇到的问题 / Describe the bug clearly
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤 / Steps to Reproduce
|
||||
description: 如何复现这个问题 / How to reproduce
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为 / Expected Behavior
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: BugPack 版本 / Version
|
||||
placeholder: "0.1.0"
|
||||
|
||||
- type: input
|
||||
id: node-version
|
||||
attributes:
|
||||
label: Node.js 版本 / Node.js Version
|
||||
placeholder: ">= 18.x"
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: 操作系统 / OS
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & Discussions
|
||||
url: https://github.com/duhuazhu/BugPack/discussions
|
||||
about: Ask questions, share ideas, or discuss BugPack here
|
||||
@@ -1,17 +0,0 @@
|
||||
name: Feature Request
|
||||
description: 提一个新功能建议 / Suggest a new feature
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 功能描述 / Description
|
||||
description: 你希望 BugPack 增加什么功能?/ What feature would you like?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景 / Use Case
|
||||
description: 这个功能解决什么问题?/ What problem does this solve?
|
||||
@@ -1,13 +0,0 @@
|
||||
## 改了什么 / What Changed
|
||||
|
||||
<!-- 简要描述你的改动 / Brief description of changes -->
|
||||
|
||||
## 为什么改 / Why
|
||||
|
||||
<!-- 解决了什么问题或实现了什么功能 / What problem does this solve -->
|
||||
|
||||
## 测试 / Testing
|
||||
|
||||
- [ ] `npx tsc --noEmit` 通过
|
||||
- [ ] `npm run build` 通过
|
||||
- [ ] 已在本地测试功能正常
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ">= 18"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -1,38 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project internal
|
||||
todo/
|
||||
.mcp.json
|
||||
/产品录屏/
|
||||
/录制项目的视频/
|
||||
BugPack-产品构思文档.md
|
||||
|
||||
# Build cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.pid
|
||||
@@ -1,23 +0,0 @@
|
||||
src/
|
||||
docs/
|
||||
todo/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
.cursor/
|
||||
.windsurf/
|
||||
.mcp.json
|
||||
.gitignore
|
||||
tsconfig*.json
|
||||
vite.config.ts
|
||||
postcss.config.js
|
||||
tailwind.config.js
|
||||
BugPack-*.md
|
||||
node_modules/
|
||||
@@ -1,25 +0,0 @@
|
||||
English | [中文](CHANGELOG.zh-CN.md)
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
- Bug management with multi-project support
|
||||
- Screenshot paste (Ctrl+V) and annotation tools (arrow, rectangle, text, numbering, etc.)
|
||||
- AI instruction generation from annotated screenshots
|
||||
- MCP Server integration for 10+ AI coding tools (Claude Code, Cursor, Windsurf, VS Code, Cline, etc.)
|
||||
- Batch operations (multi-select, batch delete, batch status change, batch export)
|
||||
- Import/Export (.bugpack zip format)
|
||||
- Third-party platform integration (Jira, Linear, Zentao, TAPD)
|
||||
- Dark/Light theme support
|
||||
- i18n support (Chinese / English)
|
||||
- Keyboard shortcuts (Ctrl+N, Ctrl+Enter, Ctrl+Z/Y, etc.)
|
||||
- OpenClaw Skills for AI agent workflows
|
||||
- GitHub Actions CI pipeline (Node >= 18)
|
||||
- Community files: LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY
|
||||
@@ -1,25 +0,0 @@
|
||||
[English](CHANGELOG.md) | 中文
|
||||
|
||||
# 更新日志
|
||||
|
||||
本项目的所有重要变更都将记录在此文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/),版本号遵循 [语义化版本](https://semver.org/)。
|
||||
|
||||
## [1.0.0] - 2026-03-18
|
||||
|
||||
### 新增
|
||||
|
||||
- Bug 管理,支持多项目
|
||||
- 截图粘贴(Ctrl+V)和标注工具(箭头、矩形、文字、编号等)
|
||||
- 从标注截图生成 AI 修复指令
|
||||
- MCP Server 集成,支持 10+ AI 编程工具(Claude Code、Cursor、Windsurf、VS Code、Cline 等)
|
||||
- 批量操作(多选、批量删除、批量改状态、批量导出)
|
||||
- 导入/导出(.bugpack zip 格式)
|
||||
- 第三方平台集成(Jira、Linear、禅道、TAPD)
|
||||
- 深色/浅色主题
|
||||
- 国际化(中文/英文)
|
||||
- 快捷键(Ctrl+N、Ctrl+Enter、Ctrl+Z/Y 等)
|
||||
- OpenClaw 技能包,支持 AI 工作流
|
||||
- GitHub Actions CI 流水线(Node >= 18)
|
||||
- 社区文件:LICENSE、CONTRIBUTING、CODE_OF_CONDUCT、SECURITY
|
||||
@@ -1,30 +0,0 @@
|
||||
English | [中文](CODE_OF_CONDUCT.zh-CN.md)
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information without explicit permission
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting the maintainer directly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
@@ -1,30 +0,0 @@
|
||||
[English](CODE_OF_CONDUCT.md) | 中文
|
||||
|
||||
# 贡献者行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
作为成员、贡献者和领导者,我们承诺让每个人在社区中都能拥有无骚扰的参与体验,无论年龄、体型、残障与否、种族、性别特征、性别认同与表达、经验水平、教育程度、社会经济地位、国籍、外貌、宗教或性取向。
|
||||
|
||||
## 我们的标准
|
||||
|
||||
有助于营造积极环境的行为:
|
||||
|
||||
- 使用友好和包容的语言
|
||||
- 尊重不同的观点和经验
|
||||
- 优雅地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
|
||||
不可接受的行为:
|
||||
|
||||
- 挑衅、侮辱或贬损性评论,以及人身或政治攻击
|
||||
- 公开或私下骚扰
|
||||
- 未经明确许可发布他人的私人信息
|
||||
|
||||
## 执行
|
||||
|
||||
如遇到滥用、骚扰或其他不可接受的行为,可通过提交 Issue 或直接联系维护者进行举报。
|
||||
|
||||
## 归属
|
||||
|
||||
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) 2.1 版。
|
||||
@@ -1,55 +0,0 @@
|
||||
English | [中文](CONTRIBUTING.zh-CN.md)
|
||||
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in BugPack!
|
||||
|
||||
## Using BugPack
|
||||
|
||||
If you just want to use BugPack, no need to clone — run it directly via npm:
|
||||
|
||||
```bash
|
||||
# Start Web UI
|
||||
npx bugpack-mcp
|
||||
|
||||
# Start MCP Server (for AI coding tools)
|
||||
npx bugpack-mcp --mcp
|
||||
```
|
||||
|
||||
## Contributing Code
|
||||
|
||||
To contribute code to BugPack:
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. `npm install`
|
||||
3. `npm run dev:all` to start dev mode (frontend + backend with hot reload)
|
||||
4. Develop on a `feature/xxx` branch
|
||||
5. Submit a PR
|
||||
|
||||
## Dev Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev:all` | Start frontend + backend (dev mode) |
|
||||
| `npm run build` | Production build |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # React frontend
|
||||
│ ├── components/ # UI components
|
||||
│ ├── stores/ # Zustand state management
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── i18n/ # Internationalization (zh/en)
|
||||
│ └── utils/ # Utilities (instruction generation)
|
||||
├── server/ # Express backend
|
||||
│ ├── routes/ # API routes
|
||||
│ └── db.ts # SQLite database
|
||||
└── mcp/ # MCP Server (stdio transport)
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Ensure `npx tsc --noEmit` passes before submitting
|
||||
- Update i18n files in `src/client/i18n/` for both zh and en if applicable
|
||||
@@ -1,55 +0,0 @@
|
||||
[English](CONTRIBUTING.md) | 中文
|
||||
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 BugPack 的关注!
|
||||
|
||||
## 使用 BugPack
|
||||
|
||||
如果只是使用 BugPack,无需克隆仓库,直接通过 npm 运行:
|
||||
|
||||
```bash
|
||||
# 启动 Web UI
|
||||
npx bugpack-mcp
|
||||
|
||||
# 启动 MCP Server(供 AI 编程工具使用)
|
||||
npx bugpack-mcp --mcp
|
||||
```
|
||||
|
||||
## 贡献代码
|
||||
|
||||
参与 BugPack 开发:
|
||||
|
||||
1. Fork 并克隆仓库
|
||||
2. `npm install`
|
||||
3. `npm run dev:all` 启动开发模式(前端 + 后端热重载)
|
||||
4. 在 `feature/xxx` 分支上开发
|
||||
5. 提交 PR
|
||||
|
||||
## 开发命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `npm run dev:all` | 启动前端 + 后端(开发模式) |
|
||||
| `npm run build` | 生产构建 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # React 前端
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── i18n/ # 国际化(中文/英文)
|
||||
│ └── utils/ # 工具函数(指令生成)
|
||||
├── server/ # Express 后端
|
||||
│ ├── routes/ # API 路由
|
||||
│ └── db.ts # SQLite 数据库
|
||||
└── mcp/ # MCP Server(stdio 传输)
|
||||
```
|
||||
|
||||
## 规范
|
||||
|
||||
- 提交前确保 `npx tsc --noEmit` 通过
|
||||
- 如涉及界面文案,需同时更新 `src/client/i18n/` 中的中英文文件
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026-present duhuazhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,317 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/public/favicon.svg" width="80" alt="BugPack">
|
||||
</p>
|
||||
|
||||
<h1 align="center">BugPack</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Package bug screenshots into AI-ready fix instructions in 30 seconds</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml"><img src="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/v/bugpack-mcp.svg" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/dm/bugpack-mcp.svg" alt="npm downloads"></a>
|
||||
<a href="https://github.com/duhuazhu/BugPack/blob/main/LICENSE"><img src="https://img.shields.io/github/license/duhuazhu/BugPack.svg" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> · <a href="#mcp-configuration">MCP Config</a> · <a href="#openclaw-skills">OpenClaw</a> · <a href="#features">Features</a> · <a href="#platform-integrations">Integrations</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">中文</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## What is BugPack?
|
||||
|
||||
BugPack is a **local-first** tool that packages bug screenshots into structured, AI-ready fix instructions.
|
||||
|
||||
QA drops a screenshot in the chat → you `Ctrl+V` paste it into BugPack → annotate the issue → generate structured instructions → feed them to your AI coding agent.
|
||||
|
||||
Or skip the copy-paste entirely: BugPack's built-in **MCP Server** lets any MCP-compatible AI coding tool (Claude Code, Cursor, Windsurf, Cline, etc.) **read bug context and fix code automatically**.
|
||||
|
||||
## Why BugPack?
|
||||
|
||||
AI coding agents changed how we write code, but not how we **communicate bug context**.
|
||||
|
||||
Every bug fix still requires: save screenshot → create file → write paths → describe the issue → paste to AI.
|
||||
10 bugs a day = **1-2 hours of pure repetition**.
|
||||
|
||||
BugPack compresses this to **30 seconds**.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **OS** — Windows / macOS / Linux
|
||||
- **Browser** — Chrome / Edge / Firefox (Chrome recommended)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npx bugpack-mcp
|
||||
```
|
||||
|
||||
Open `http://localhost:3456` and `Ctrl+V` your first bug screenshot to get started.
|
||||
|
||||
## MCP Configuration
|
||||
|
||||
BugPack works with **any MCP-compatible AI coding tool**. Here are common examples — configure other tools the same way.
|
||||
|
||||
**Claude Code** — add to `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / VS Code / Cline / Roo Code / Trae / MarsCode / Augment</b></summary>
|
||||
|
||||
**Cursor** (`.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**VS Code** (`.vscode/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cline / Roo Code** (VS Code Settings):
|
||||
|
||||
```json
|
||||
{
|
||||
"cline.mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trae** (`trae/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MarsCode** (Settings → MCP):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Augment** (`augment/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All other MCP-compatible tools follow the same pattern — just point `command` to `npx` and `args` to `["bugpack-mcp", "--mcp"]`.
|
||||
|
||||
</details>
|
||||
|
||||
Once configured, just tell your AI:
|
||||
|
||||
- **"Show me pending bugs"** → AI calls `list_bugs`
|
||||
- **"Fix bug #3"** → AI calls `get_bug_context`, locates code, and fixes it
|
||||
- **"Mark bug #3 as fixed"** → AI calls `mark_bug_status`
|
||||
|
||||
## OpenClaw Skills
|
||||
|
||||
BugPack provides [OpenClaw](https://github.com/openclaw/openclaw) Skills for AI agents that support the OpenClaw protocol.
|
||||
|
||||
**Install via CLI:**
|
||||
|
||||
```bash
|
||||
clawhub install bugpack
|
||||
```
|
||||
|
||||
**Or add to `~/.openclaw/openclaw.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"bugpack": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"extraDirs": ["./skills"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Or manually**: copy the `skills/` directory from this repo into your workspace or `~/.openclaw/skills/`.
|
||||
|
||||
BugPack includes 3 built-in skills:
|
||||
|
||||
| Skill | Triggers | Description |
|
||||
|-------|----------|-------------|
|
||||
| `bugpack-list-bugs` | "show me bugs" / "list bugs" | List all bugs with status filtering |
|
||||
| `bugpack-view-bug` | "view bug" / "bug context" | Get full bug details with screenshots and related files |
|
||||
| `bugpack-fix-bug` | "fix bug" / "repair bug" | Read context → locate code → apply fix → update status |
|
||||
|
||||
Once installed, just tell your AI:
|
||||
|
||||
- **"Show me bugs"** → AI calls `bugpack-list-bugs`
|
||||
- **"View bug details"** → AI calls `bugpack-view-bug`, shows screenshots and context
|
||||
- **"Fix this bug"** → AI calls `bugpack-fix-bug`, locates code, fixes it, and marks as done
|
||||
|
||||
> **Note:** OpenClaw Skills require BugPack server running (`npx bugpack-mcp`). Skills communicate with the local server via REST API on `http://localhost:3456`.
|
||||
|
||||
## Features
|
||||
|
||||
### Screenshots & Annotations
|
||||
|
||||
- **Clipboard paste** — `Ctrl+V` to paste screenshots directly from any chat tool
|
||||
- **Drag & drop** — drop image files onto the canvas
|
||||
- **9 annotation tools** — drag/pan, select, rectangle, arrow, text, numbering, highlight, pen, mosaic
|
||||
- **Compare mode** — side-by-side comparison of "current" vs "expected" behavior
|
||||
- **Undo / Redo** — full operation history
|
||||
|
||||
### AI Instruction Generation
|
||||
|
||||
- **One-click generation** — produces structured Markdown fix instructions
|
||||
- **Universal MCP support** — works with any MCP-compatible AI coding tool
|
||||
|
||||
### MCP Server
|
||||
|
||||
Built-in MCP Server lets AI coding agents **directly access bug context**:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_bugs` | List all bugs with status/project filtering |
|
||||
| `get_bug_context` | Get full bug context (description + screenshots + environment + files) |
|
||||
| `get_bug_screenshot` | Get a single annotated screenshot (base64) |
|
||||
| `mark_bug_status` | Update bug status |
|
||||
| `add_fix_note` | Add fix notes after repair |
|
||||
|
||||
### Platform Integrations
|
||||
|
||||
Import bugs from project management platforms, sync fix status back:
|
||||
|
||||
- **Zentao** · **Jira** · **Linear** · **TAPD**
|
||||
|
||||
### More
|
||||
|
||||
- **100% local** — data never leaves your machine, SQLite storage
|
||||
- **Multi-project** — manage bugs independently per project
|
||||
- **Dark / Light theme** — follow your preference
|
||||
- **i18n** — Chinese / English
|
||||
- **Keyboard shortcuts** — efficient workflow
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Paste screenshot → Describe issue → Generate instructions → AI fixes code
|
||||
│ │ │
|
||||
│ ┌──────────────┘ │
|
||||
▼ ▼ ▼
|
||||
BugPack Copy Markdown MCP Server
|
||||
Canvas paste to AI tool AI reads & fixes directly
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data is stored locally:
|
||||
|
||||
- **Data directory**: `~/.bugpack/data/`
|
||||
- **Database**: `bugpack.db` (SQLite)
|
||||
- **Screenshots**: `uploads/{ProjectName}/{uuid}.{ext}`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | React 18 · TypeScript · Tailwind CSS · Zustand |
|
||||
| Annotation | Fabric.js v6 |
|
||||
| Backend | Node.js · Express |
|
||||
| Database | SQLite (better-sqlite3, WAL mode) |
|
||||
| MCP | @modelcontextprotocol/sdk |
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**If BugPack saves you time, give it a Star!**
|
||||
|
||||
[](https://ko-fi.com/W7W51W5EN5)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="Alipay"> <img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="WeChat Pay">
|
||||
|
||||
</div>
|
||||
@@ -1,310 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/public/favicon.svg" width="80" alt="BugPack">
|
||||
</p>
|
||||
|
||||
<h1 align="center">BugPack</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>30 秒将 Bug 截图打包为 AI 可读的修复指令</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml"><img src="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/v/bugpack-mcp.svg" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/dm/bugpack-mcp.svg" alt="npm downloads"></a>
|
||||
<a href="https://github.com/duhuazhu/BugPack/blob/main/LICENSE"><img src="https://img.shields.io/github/license/duhuazhu/BugPack.svg" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#快速开始">快速开始</a> · <a href="#mcp-配置">MCP 配置</a> · <a href="#openclaw-技能">OpenClaw</a> · <a href="#功能特性">功能特性</a> · <a href="#平台集成">平台集成</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 中文
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 什么是 BugPack?
|
||||
|
||||
BugPack 是一个**本地优先**的工具,将 Bug 截图打包为结构化的、AI 可读的修复指令。
|
||||
|
||||
测试人员在群里发了截图 → 你 `Ctrl+V` 粘贴到 BugPack → 标注问题区域 → 生成结构化指令 → 喂给 AI 编程助手。
|
||||
|
||||
或者跳过复制粘贴:BugPack 内置 **MCP Server**,让任何兼容 MCP 的 AI 编程工具(Claude Code、Cursor、Windsurf、Cline 等)**直接读取 Bug 上下文并自动修复代码**。
|
||||
|
||||
## 为什么用 BugPack?
|
||||
|
||||
AI 编程助手改变了我们写代码的方式,但没有改变我们**传递 Bug 上下文**的方式。
|
||||
|
||||
每次修 Bug 仍然需要:保存截图 → 创建文件 → 写路径 → 描述问题 → 粘贴给 AI。
|
||||
一天 10 个 Bug = **1-2 小时的纯重复劳动**。
|
||||
|
||||
BugPack 把这个过程压缩到 **30 秒**。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **操作系统** — Windows / macOS / Linux
|
||||
- **浏览器** — Chrome / Edge / Firefox(推荐 Chrome)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
npx bugpack-mcp
|
||||
```
|
||||
|
||||
打开 `http://localhost:3456`,`Ctrl+V` 粘贴你的第一张 Bug 截图即可开始。
|
||||
|
||||
## MCP 配置
|
||||
|
||||
BugPack 兼容**任何支持 MCP 的 AI 编程工具**。以下是常见配置示例。
|
||||
|
||||
**Claude Code** — 添加到 `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / VS Code / Cline / Roo Code / Trae / MarsCode / Augment</b></summary>
|
||||
|
||||
**Cursor** (`.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**VS Code** (`.vscode/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cline / Roo Code**(VS Code 设置):
|
||||
|
||||
```json
|
||||
{
|
||||
"cline.mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trae** (`trae/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MarsCode**(设置 → MCP):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Augment** (`augment/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
所有兼容 MCP 的工具配置方式相同 — `command` 指向 `npx`,`args` 设为 `["bugpack-mcp", "--mcp"]`。
|
||||
|
||||
</details>
|
||||
|
||||
配置完成后,直接告诉你的 AI:
|
||||
|
||||
- **"显示待修复的 Bug"** → AI 调用 `list_bugs`
|
||||
- **"修复 Bug #3"** → AI 调用 `get_bug_context`,定位代码并修复
|
||||
- **"标记 Bug #3 为已修复"** → AI 调用 `mark_bug_status`
|
||||
|
||||
## OpenClaw 技能
|
||||
|
||||
BugPack 提供 [OpenClaw](https://github.com/openclaw/openclaw) 技能包,支持 OpenClaw 协议的 AI 助手可直接使用。
|
||||
|
||||
**通过 CLI 安装:**
|
||||
|
||||
```bash
|
||||
clawhub install bugpack
|
||||
```
|
||||
|
||||
**或添加到 `~/.openclaw/openclaw.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"bugpack": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"extraDirs": ["./skills"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**或手动安装**:将本仓库的 `skills/` 目录复制到你的工作区或 `~/.openclaw/skills/`。
|
||||
|
||||
BugPack 包含 3 个内置技能:
|
||||
|
||||
| 技能 | 触发方式 | 说明 |
|
||||
|------|----------|------|
|
||||
| `bugpack-list-bugs` | "显示 Bug" / "列出 Bug" | 列出所有 Bug,支持状态过滤 |
|
||||
| `bugpack-view-bug` | "查看 Bug" / "Bug 详情" | 获取完整 Bug 详情,包含截图和关联文件 |
|
||||
| `bugpack-fix-bug` | "修复 Bug" / "修 Bug" | 读取上下文 → 定位代码 → 修复 → 更新状态 |
|
||||
|
||||
> **注意:** OpenClaw 技能需要 BugPack 服务运行中(`npx bugpack-mcp`)。技能通过 REST API 与本地服务通信(`http://localhost:3456`)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 截图与标注
|
||||
|
||||
- **剪贴板粘贴** — `Ctrl+V` 从任何聊天工具直接粘贴截图
|
||||
- **拖放上传** — 拖放图片文件到画布
|
||||
- **9 种标注工具** — 拖拽/平移、选择、矩形、箭头、文字、编号、高亮、画笔、马赛克
|
||||
- **对比模式** — 并排对比"当前效果"与"预期效果"
|
||||
- **撤销/重做** — 完整操作历史
|
||||
|
||||
### AI 指令生成
|
||||
|
||||
- **一键生成** — 生成结构化 Markdown 修复指令
|
||||
- **通用 MCP 支持** — 兼容任何支持 MCP 的 AI 编程工具
|
||||
|
||||
### MCP Server
|
||||
|
||||
内置 MCP Server 让 AI 编程助手**直接访问 Bug 上下文**:
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `list_bugs` | 列出所有 Bug,支持状态/项目过滤 |
|
||||
| `get_bug_context` | 获取完整 Bug 上下文(描述 + 截图 + 环境 + 文件) |
|
||||
| `get_bug_screenshot` | 获取单张标注截图(base64) |
|
||||
| `mark_bug_status` | 更新 Bug 状态 |
|
||||
| `add_fix_note` | 修复后添加备注 |
|
||||
|
||||
### 平台集成
|
||||
|
||||
从项目管理平台导入 Bug,同步修复状态:
|
||||
|
||||
- **禅道** · **Jira** · **Linear** · **TAPD**
|
||||
|
||||
### 更多
|
||||
|
||||
- **100% 本地** — 数据不离开你的机器,SQLite 存储
|
||||
- **多项目管理** — 按项目独立管理 Bug
|
||||
- **深色/浅色主题** — 跟随你的偏好
|
||||
- **国际化** — 中文 / 英文
|
||||
- **快捷键** — 高效工作流
|
||||
|
||||
## 工作流
|
||||
|
||||
```
|
||||
粘贴截图 → 描述问题 → 生成指令 → AI 修复代码
|
||||
│ │ │
|
||||
│ ┌────────────┘ │
|
||||
▼ ▼ ▼
|
||||
BugPack 复制 Markdown MCP Server
|
||||
画布 粘贴给 AI 工具 AI 直接读取并修复
|
||||
```
|
||||
|
||||
## 数据存储
|
||||
|
||||
所有数据存储在本地:
|
||||
|
||||
- **数据目录**:`~/.bugpack/data/`
|
||||
- **数据库**:`bugpack.db`(SQLite)
|
||||
- **截图**:`uploads/{项目名}/{uuid}.{ext}`
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端 | React 18 · TypeScript · Tailwind CSS · Zustand |
|
||||
| 标注 | Fabric.js v6 |
|
||||
| 后端 | Node.js · Express |
|
||||
| 数据库 | SQLite (better-sqlite3, WAL 模式) |
|
||||
| MCP | @modelcontextprotocol/sdk |
|
||||
|
||||
## 贡献
|
||||
|
||||
请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果 BugPack 帮到了你,请给个 Star!**
|
||||
|
||||
[](https://ko-fi.com/W7W51W5EN5)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="支付宝"> <img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="微信支付">
|
||||
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
English | [中文](SECURITY.zh-CN.md)
|
||||
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in BugPack, please report it responsibly:
|
||||
|
||||
1. **Do NOT** open a public GitHub issue
|
||||
2. Email the maintainer directly or use [GitHub private vulnerability reporting](https://github.com/duhuazhu/BugPack/security/advisories/new)
|
||||
3. Include steps to reproduce the issue
|
||||
|
||||
We will respond within 72 hours and work on a fix as soon as possible.
|
||||
|
||||
## Scope
|
||||
|
||||
BugPack runs **100% locally** on your machine. All data (SQLite database, screenshots) is stored in `~/.bugpack/data/` and never transmitted externally.
|
||||
|
||||
The MCP Server communicates via **stdio** only — no network exposure.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.x | Yes |
|
||||
| < 1.0 | No |
|
||||
@@ -1,26 +0,0 @@
|
||||
[English](SECURITY.md) | 中文
|
||||
|
||||
# 安全策略
|
||||
|
||||
## 报告漏洞
|
||||
|
||||
如果你发现 BugPack 中的安全漏洞,请负责任地报告:
|
||||
|
||||
1. **不要**创建公开的 GitHub Issue
|
||||
2. 直接联系维护者,或使用 [GitHub 私密漏洞报告](https://github.com/duhuazhu/BugPack/security/advisories/new)
|
||||
3. 附上复现步骤
|
||||
|
||||
我们会在 72 小时内回复,并尽快修复。
|
||||
|
||||
## 范围
|
||||
|
||||
BugPack **100% 本地运行**。所有数据(SQLite 数据库、截图)存储在 `~/.bugpack/data/`,不会传输到外部。
|
||||
|
||||
MCP Server 仅通过 **stdio** 通信,无网络暴露。
|
||||
|
||||
## 支持版本
|
||||
|
||||
| 版本 | 是否支持 |
|
||||
|------|----------|
|
||||
| 1.x | 是 |
|
||||
| < 1.0 | 否 |
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 345 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
if (process.argv.includes('--mcp')) {
|
||||
await import(pathToFileURL(path.join(__dirname, '../dist/mcp/index.js')).href)
|
||||
} else {
|
||||
// Default port 3456 for production
|
||||
if (!process.env.PORT) process.env.PORT = '3456'
|
||||
await import(pathToFileURL(path.join(__dirname, '../dist/server/index.js')).href)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Pack bug screenshots into AI-ready fix instructions with MCP Server integration" />
|
||||
<meta name="theme-color" content="#002FA7" />
|
||||
<meta property="og:title" content="BugPack" />
|
||||
<meta property="og:description" content="Pack bug screenshots into AI-ready fix instructions with MCP Server integration" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://github.com/duhuazhu/BugPack" />
|
||||
<title>BugPack</title>
|
||||
<script>
|
||||
// Sync theme before render to prevent flash
|
||||
try {
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.open('GET', '/api/settings', false)
|
||||
xhr.send()
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText)
|
||||
if (data.theme === 'light') {
|
||||
document.documentElement.classList.add('light')
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-bg-primary text-text-primary">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-8061
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"name": "bugpack-mcp",
|
||||
"version": "1.0.5",
|
||||
"description": "Pack bug screenshots into AI coding instructions with MCP Server integration",
|
||||
"author": "duhuazhu",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/duhuazhu/BugPack"
|
||||
},
|
||||
"homepage": "https://github.com/duhuazhu/BugPack",
|
||||
"bugs": {
|
||||
"url": "https://github.com/duhuazhu/BugPack/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"mcp-server",
|
||||
"model-context-protocol",
|
||||
"bug",
|
||||
"bug-tracking",
|
||||
"bug-report",
|
||||
"screenshot",
|
||||
"annotation",
|
||||
"ai",
|
||||
"claude",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"vscode",
|
||||
"cline",
|
||||
"trae",
|
||||
"marscode",
|
||||
"developer-tools",
|
||||
"qa",
|
||||
"testing",
|
||||
"fix-instructions",
|
||||
"code-fix"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"bugpack-mcp": "bin/bugpack.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"dist/",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"",
|
||||
"mcp": "tsx src/mcp/index.ts",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"fabric": "^6.6.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^17.0.4",
|
||||
"morphdom": "^2.7.8",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="450 105 490 550">
|
||||
<path d="M 702.50 642.16 C694.92,644.85 689.33,644.29 681.00,640.02 C677.31,638.12 638.15,615.52 599.50,592.99 C503.93,537.26 476.74,521.16 473.00,518.04 C470.41,515.89 467.22,511.82 465.50,508.47 L 462.50 502.64 L 462.50 253.50 L 465.29 247.82 C468.69,240.89 472.25,237.67 484.24,230.67 C489.33,227.71 513.08,213.81 537.00,199.81 C560.92,185.80 590.85,168.34 603.50,161.02 C616.15,153.69 638.20,140.85 652.50,132.49 C678.83,117.09 683.42,114.15 688.26,113.64 C689.40,113.52 690.56,113.53 692.02,113.55 C692.62,113.56 693.27,113.57 694.00,113.57 C694.70,113.57 695.33,113.56 695.91,113.56 C697.46,113.54 698.67,113.53 699.86,113.66 C704.62,114.19 709.11,117.06 733.50,131.31 C746.70,139.02 772.35,153.95 790.50,164.49 C824.05,183.97 881.98,217.75 903.19,230.21 C915.17,237.24 918.45,240.19 922.30,247.38 L 924.50 251.50 L 924.50 504.50 L 921.64 509.66 C916.70,518.58 916.66,518.60 870.50,545.21 C863.35,549.33 823.75,572.43 782.50,596.54 C741.25,620.66 705.25,641.19 702.50,642.16 ZM 717.16 351.25 C716.97,359.36 717.09,366.00 717.43,366.00 C718.37,366.00 718.98,365.61 781.00,325.17 C799.97,312.80 828.10,294.59 843.50,284.71 C858.90,274.82 871.85,266.42 872.29,266.03 C873.08,265.32 834.82,242.08 828.50,239.44 C824.41,237.73 822.73,237.67 817.71,239.06 C813.09,240.35 808.52,244.25 771.50,278.49 C725.40,321.12 724.22,322.31 720.64,329.86 C717.63,336.22 717.49,337.13 717.16,351.25 ZM 663.00 361.57 C666.58,363.93 669.84,365.89 670.25,365.93 C671.64,366.06 671.05,343.62 669.51,337.69 C666.69,326.86 664.00,323.17 647.82,307.83 C622.45,283.77 577.69,242.89 574.65,240.98 C570.40,238.32 563.84,237.62 559.62,239.39 C557.67,240.20 546.79,246.41 535.45,253.18 C518.83,263.09 515.14,265.68 516.45,266.49 C520.99,269.30 650.45,353.30 663.00,361.57 ZM 728.28 386.39 C727.94,386.73 748.81,387.00 774.66,387.00 L 821.64 387.00 L 825.21 384.25 C827.17,382.74 836.75,374.75 846.50,366.50 C856.26,358.25 866.42,349.80 869.09,347.73 C871.76,345.65 875.19,341.83 876.72,339.23 L 879.50 334.50 L 879.86 311.75 C880.10,296.21 879.88,289.00 879.16,289.00 C877.81,289.00 863.66,298.04 809.77,333.31 C793.12,344.21 768.11,360.47 754.19,369.45 C740.28,378.43 728.61,386.05 728.28,386.39 ZM 563.40 384.75 L 566.39 387.00 L 613.39 387.00 C639.25,387.00 659.97,386.63 659.45,386.18 C658.93,385.73 652.65,381.61 645.50,377.02 C638.35,372.43 627.55,365.46 621.50,361.53 C615.45,357.60 606.33,351.71 601.24,348.44 C596.15,345.17 580.61,335.08 566.71,326.00 C517.54,293.89 509.90,289.00 508.95,289.00 C508.38,289.00 508.00,297.70 508.00,310.98 C508.00,328.98 508.30,333.70 509.63,337.04 C510.98,340.40 515.63,344.80 535.83,361.81 C549.35,373.19 561.75,383.51 563.40,384.75 ZM 532.00 508.17 C532.00,508.63 549.69,509.00 571.32,509.00 C602.96,509.00 611.60,508.71 615.57,507.53 C620.89,505.95 624.09,502.99 641.55,483.50 C646.48,478.00 653.93,469.67 658.12,465.00 C664.62,457.74 667.85,454.42 669.45,450.45 C671.03,446.52 671.02,441.96 671.00,432.32 C671.00,431.23 671.00,430.08 671.00,428.86 C671.00,417.94 670.70,409.00 670.33,409.00 C669.97,409.00 664.90,412.44 659.08,416.64 C617.21,446.89 562.08,486.31 543.75,499.12 C537.29,503.64 532.00,507.71 532.00,508.17 ZM 771.81 507.42 C775.54,508.56 784.52,508.88 815.58,508.93 C837.08,508.97 854.95,508.72 855.30,508.37 C855.92,507.75 849.23,502.83 804.50,471.01 C792.40,462.40 774.17,449.35 764.00,442.01 C725.01,413.88 718.15,409.00 717.58,409.00 C717.26,409.00 717.00,418.36 717.00,429.81 L 717.00 450.61 L 732.62 468.06 C764.31,503.46 766.69,505.84 771.81,507.42 ZM 543.00 476.25 C543.00,476.66 543.19,477.00 543.42,477.00 C543.66,477.00 552.09,471.29 562.17,464.31 C584.47,448.87 594.78,441.77 619.76,424.68 L 639.01 411.50 L 608.76 411.21 C592.12,411.05 577.10,411.17 575.39,411.48 C567.32,412.93 558.62,422.32 555.93,432.50 C555.35,434.70 552.20,445.27 548.93,456.00 C545.67,466.73 543.00,475.84 543.00,476.25 ZM 830.79 467.70 C837.23,472.21 843.00,475.92 843.61,475.95 C844.62,476.00 842.43,468.08 831.96,433.87 C829.54,425.96 828.20,423.33 824.48,419.19 C817.13,411.02 816.49,410.90 780.27,411.22 L 749.05 411.50 L 784.06 435.50 C803.32,448.70 824.35,463.19 830.79,467.70 Z" fill="#002FA7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: bugpack-fix-bug
|
||||
description: "Fix a bug from BugPack by reading its context, locating code, applying fixes, and updating status. Use when: user asks to fix, repair, or resolve a bug. NOT for: just listing bugs (use bugpack-list-bugs) or just viewing bug details (use bugpack-view-bug)."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F527"
|
||||
---
|
||||
|
||||
# BugPack - Fix Bug
|
||||
|
||||
Read bug context from BugPack, locate the relevant code, apply a fix, and mark the bug as fixed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Get bug context**: Call `GET http://localhost:3456/api/bugs/:id` to fetch full bug details including description, screenshots, environment, and related files.
|
||||
|
||||
2. **Analyze the bug**: Read the description and examine the screenshots to understand what is broken and what the expected behavior should be.
|
||||
|
||||
3. **Locate code**: Use the `relatedFiles` array from the bug context to find the relevant source files. If `relatedFiles` is empty, use the `pagePath` and `description` to search the codebase.
|
||||
|
||||
4. **Apply fix**: Edit the source code to fix the described issue. Follow the project's existing code style and conventions.
|
||||
|
||||
5. **Mark as fixed**: After applying the fix, call `PATCH http://localhost:3456/api/bugs/:id` with:
|
||||
```json
|
||||
{ "status": "fixed" }
|
||||
```
|
||||
|
||||
6. **Add fix note** (optional): Call `PATCH http://localhost:3456/api/bugs/:id` with a description update to document what was changed.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Step 1: Get bug context
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
|
||||
# Step 5: Mark as fixed
|
||||
PATCH http://localhost:3456/api/bugs/abc-123
|
||||
Content-Type: application/json
|
||||
|
||||
{ "status": "fixed" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": "abc-123",
|
||||
"status": "fixed"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: bugpack-list-bugs
|
||||
description: "List all tracked bugs from BugPack with status and project filtering. Use when: user asks about bugs, pending issues, bug lists, or wants to see what needs fixing. NOT for: viewing detailed bug context (use bugpack-view-bug) or fixing bugs (use bugpack-fix-bug)."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F41B"
|
||||
---
|
||||
|
||||
# BugPack - List Bugs
|
||||
|
||||
Query the BugPack local server to list all tracked bugs.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs` to fetch all bugs.
|
||||
- Optional query param: `?project_id=<id>` to filter by project.
|
||||
2. Parse the JSON response. Each bug has: `id`, `title`, `description`, `status`, `priority`, `project_id`, `created_at`, `updated_at`.
|
||||
3. Present the list in a readable table format, grouped by status (`open` / `fixed` / `closed`).
|
||||
4. If no bugs are found, tell the user there are no tracked bugs.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "abc-123",
|
||||
"title": "Button click not working",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"created_at": "2026-03-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
name: bugpack-view-bug
|
||||
description: "View detailed bug context from BugPack including screenshots, environment info, and related files. Use when: user wants to see bug details, screenshots, or understand a specific bug before fixing. NOT for: listing all bugs (use bugpack-list-bugs) or directly fixing bugs (use bugpack-fix-bug)."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F50D"
|
||||
---
|
||||
|
||||
# BugPack - View Bug Details
|
||||
|
||||
Fetch full bug context from BugPack, including description, screenshots, environment info, and related files.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs/:id` to get the full bug details.
|
||||
2. The response includes:
|
||||
- `title`, `description`, `status`, `priority`
|
||||
- `pagePath` — the page/route where the bug occurs
|
||||
- `device`, `browser` — environment info
|
||||
- `relatedFiles` — array of file paths related to the bug
|
||||
- `screenshots` — array of screenshot objects with `id`, `name`, `original_path`, `annotated_path`
|
||||
3. Display the bug info in a structured format.
|
||||
4. If the bug has screenshots, mention them and offer to show annotated versions.
|
||||
5. If `relatedFiles` are listed, use them to locate relevant source code.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": "abc-123",
|
||||
"title": "Button click not working",
|
||||
"description": "The submit button on the login page does not respond to clicks",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"pagePath": "/login",
|
||||
"device": "Desktop",
|
||||
"browser": "Chrome 120",
|
||||
"relatedFiles": ["src/pages/Login.tsx", "src/components/SubmitButton.tsx"],
|
||||
"screenshots": [
|
||||
{
|
||||
"id": "ss-001",
|
||||
"name": "login-bug.png",
|
||||
"original_path": "/uploads/MyProject/original.png",
|
||||
"annotated_path": "/uploads/MyProject/annotated.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
name: bugpack
|
||||
description: "BugPack - AI-powered bug tracking and fixing toolkit. List bugs, view bug details with screenshots, and fix bugs automatically. Includes three workflows: list-bugs, view-bug, fix-bug. Requires BugPack server running locally."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F4E6"
|
||||
---
|
||||
|
||||
# BugPack
|
||||
|
||||
AI-powered bug tracking and fixing toolkit. List, view, and fix bugs from BugPack.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Start BugPack server first:
|
||||
|
||||
```bash
|
||||
npx bugpack-mcp
|
||||
```
|
||||
|
||||
## Skill 1: List Bugs
|
||||
|
||||
Query all tracked bugs with optional filtering.
|
||||
|
||||
### Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs` to fetch all bugs.
|
||||
- Optional: `?project_id=<id>` to filter by project.
|
||||
2. Each bug has: `id`, `title`, `description`, `status`, `priority`, `project_id`, `created_at`.
|
||||
3. Present results grouped by status (`pending` / `fixed` / `closed`).
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skill 2: View Bug Details
|
||||
|
||||
Fetch full bug context including screenshots, environment, and related files.
|
||||
|
||||
### Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs/:id` for full details.
|
||||
2. Response includes: `title`, `description`, `status`, `priority`, `pagePath`, `device`, `browser`, `relatedFiles`, `screenshots`.
|
||||
3. Use `relatedFiles` to locate relevant source code.
|
||||
4. Screenshots have `original_path` and `annotated_path`.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skill 3: Fix Bug
|
||||
|
||||
Read bug context, locate code, apply fix, and update status.
|
||||
|
||||
### Instructions
|
||||
|
||||
1. **Get context**: `GET http://localhost:3456/api/bugs/:id`
|
||||
2. **Analyze**: Read description and examine screenshots.
|
||||
3. **Locate code**: Use `relatedFiles` or search by `pagePath` and `description`.
|
||||
4. **Apply fix**: Edit source code following project conventions.
|
||||
5. **Mark fixed**: `PATCH http://localhost:3456/api/bugs/:id` with `{ "status": "fixed" }`
|
||||
6. **Add note** (optional): Update description to document what was changed.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
# Get bug context
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
|
||||
# Mark as fixed
|
||||
PATCH http://localhost:3456/api/bugs/abc-123
|
||||
Content-Type: application/json
|
||||
|
||||
{ "status": "fixed" }
|
||||
```
|
||||
@@ -1,7 +0,0 @@
|
||||
startCommand:
|
||||
type: stdio
|
||||
configSchema:
|
||||
type: object
|
||||
properties: {}
|
||||
commandFunction: |-
|
||||
(config) => ({ command: 'npx', args: ['bugpack-mcp', '--mcp'] })
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user