Compare commits

..

13 Commits

Author SHA1 Message Date
权益小助手开发 c4af8548b6 chore: 触发部署验证 2026-05-18 20:14:47 +08:00
权益小助手开发 9f84f96792 chore: 更新backend子模块(platformCount自动统计) 2026-05-18 19:45:38 +08:00
权益小助手开发 559e2ee0f2 chore: 更新backend子模块(修复图片跨域拦截) 2026-05-17 13:16:59 +08:00
权益小助手开发 44b181d148 chore: 更新backend子模块(CSP img-src通配符) 2026-05-17 13:10:53 +08:00
权益小助手开发 6804199182 chore: 更新backend子模块(修复CSP图片拦截) 2026-05-17 13:07:59 +08:00
权益小助手开发 18e01290eb chore: 添加计划文档,更新miniapp子模块 2026-05-17 13:03:59 +08:00
权益小助手开发 d5459e5800 chore: 更新子模块(note对象结构+图片上传接口) 2026-05-17 12:47:06 +08:00
权益小助手开发 c7a91401df docs: 添加README 2026-05-17 12:21:24 +08:00
权益小助手开发 a7d8208f35 chore: 更新项目规则 — 明确只推送miniapp-api 2026-05-17 12:01:15 +08:00
权益小助手开发 df0a8bd238 chore: 添加项目规则文件 2026-05-17 11:58:37 +08:00
权益小助手开发 3c70a72e56 chore: 更新miniapp子模块 2026-05-17 11:51:44 +08:00
权益小助手开发 fbd3b51bdd chore: 更新miniapp子模块(fingerprint简化+双向同步) 2026-05-17 11:42:54 +08:00
权益小助手开发 7d90523164 Release v0.1.0: 小程序使用建议跳转腾讯文档 + 配置导入导出功能 2026-05-04 17:09:07 +08:00
148 changed files with 18908 additions and 12172 deletions
-26
View File
@@ -1,26 +0,0 @@
{
"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 *)"
]
}
}
-17
View File
@@ -1,17 +0,0 @@
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
-27
View File
@@ -1,27 +0,0 @@
# 服务器配置
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
View File
@@ -1,53 +0,0 @@
# 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:*
+32
View File
@@ -0,0 +1,32 @@
# 修复 /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` 即可正常响应。
+24
View File
@@ -0,0 +1,24 @@
# 项目规则
## 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
View File
@@ -0,0 +1,560 @@
# 权益小助手 - 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
View File
@@ -1,21 +0,0 @@
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"]
@@ -1,7 +0,0 @@
优酷/芒果年卡(二选一),年卡价值258元
网易云音乐年卡,年卡价值216元
夸克网盘年卡,买年卡价值198元
高德打车直达Lv6 会员
飞猪直达F4会员
淘票票,每月2张4元优惠券
退货包运费
+3 -25
View File
@@ -1,26 +1,4 @@
# 权益小助手后端服务 # 权益小助手
## 项目简介 个人权益管理小程序,支持淘宝88VIP、京东PLUS、支付宝等平台的权益记录与同步。
test deploy
本项目是「权益小助手」的后端服务,为前端应用提供 API 接口支持。当前处于项目初始化阶段,尚未开始正式开发。
## 目录结构
```
backend/
├── project.md # 项目信息、里程碑及开发规范
└── README.md # 本文件
```
## 当前状态
- 项目已创建基础文档
- 等待后续技术选型与框架搭建
## 技术栈(待定)
待需求分析完成后确定。
## 开发规范
详见 [project.md](./project.md)。
-60
View File
@@ -1,60 +0,0 @@
# 小程序后台 — 部署配置参考
> 摘录自 `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"
```
Submodule
+1
Submodule backend added at e2dae5942d
-120
View File
@@ -1,120 +0,0 @@
# 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
```
-52
View File
@@ -1,52 +0,0 @@
#!/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 ""
-105
View File
@@ -1,105 +0,0 @@
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`);
});
-18
View File
@@ -1,18 +0,0 @@
[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
-56
View File
@@ -1,56 +0,0 @@
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:
-54
View File
@@ -1,54 +0,0 @@
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.
+27
View File
@@ -0,0 +1,27 @@
-----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-----
Submodule
+1
Submodule miniapp added at 9dd495c30c
-33
View File
@@ -1,33 +0,0 @@
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";
}
}
-6928
View File
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
{
"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
View File
@@ -1,38 +0,0 @@
# 项目信息
## 项目概述
- **项目名称**:权益小助手后端服务
- **项目类型**:后端服务(纯后端项目)
- **工作目录**`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 文档规范,则直接给出结论,不强行修改后端进行适配。
-470
View File
@@ -1,470 +0,0 @@
<!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()">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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>
-33
View File
@@ -1,33 +0,0 @@
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();
-31
View File
@@ -1,31 +0,0 @@
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();
-31
View File
@@ -1,31 +0,0 @@
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
View File
@@ -1,119 +0,0 @@
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;
-26
View File
@@ -1,26 +0,0 @@
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;
-13
View File
@@ -1,13 +0,0 @@
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 };
-42
View File
@@ -1,42 +0,0 @@
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 };
-39
View File
@@ -1,39 +0,0 @@
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;
-7
View File
@@ -1,7 +0,0 @@
const notFound = (req, res, next) => {
const error = new Error(`未找到路由 - ${req.originalUrl}`);
res.status(404);
next(error);
};
module.exports = { notFound };
-80
View File
@@ -1,80 +0,0 @@
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);
-72
View File
@@ -1,72 +0,0 @@
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);
-76
View File
@@ -1,76 +0,0 @@
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);
-84
View File
@@ -1,84 +0,0 @@
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);
-106
View File
@@ -1,106 +0,0 @@
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);
-160
View File
@@ -1,160 +0,0 @@
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);
-57
View File
@@ -1,57 +0,0 @@
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);
-41
View File
@@ -1,41 +0,0 @@
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);
-163
View File
@@ -1,163 +0,0 @@
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;
-158
View File
@@ -1,158 +0,0 @@
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;
-197
View File
@@ -1,197 +0,0 @@
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;
-52
View File
@@ -1,52 +0,0 @@
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;
-79
View File
@@ -1,79 +0,0 @@
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;
-105
View File
@@ -1,105 +0,0 @@
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;
-103
View File
@@ -1,103 +0,0 @@
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;
-206
View File
@@ -1,206 +0,0 @@
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;
-268
View File
@@ -1,268 +0,0 @@
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;
-352
View File
@@ -1,352 +0,0 @@
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;
-63
View File
@@ -1,63 +0,0 @@
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;
-240
View File
@@ -1,240 +0,0 @@
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;
-447
View File
@@ -1,447 +0,0 @@
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;
-26
View File
@@ -1,26 +0,0 @@
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;
-47
View File
@@ -1,47 +0,0 @@
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
};
-65
View File
@@ -1,65 +0,0 @@
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
};
-243
View File
@@ -1,243 +0,0 @@
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
};
-101
View File
@@ -1,101 +0,0 @@
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;
-45
View File
@@ -1,45 +0,0 @@
@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
-38
View File
@@ -1,38 +0,0 @@
@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
+221
View File
@@ -0,0 +1,221 @@
---
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
View File
@@ -0,0 +1 @@
ko_fi: W7W51W5EN5
+47
View File
@@ -0,0 +1,47 @@
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
+5
View File
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,17 @@
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?
+13
View File
@@ -0,0 +1,13 @@
## 改了什么 / What Changed
<!-- 简要描述你的改动 / Brief description of changes -->
## 为什么改 / Why
<!-- 解决了什么问题或实现了什么功能 / What problem does this solve -->
## 测试 / Testing
- [ ] `npx tsc --noEmit` 通过
- [ ] `npm run build` 通过
- [ ] 已在本地测试功能正常
+28
View File
@@ -0,0 +1,28 @@
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
+38
View File
@@ -0,0 +1,38 @@
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
+23
View File
@@ -0,0 +1,23 @@
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/
+25
View File
@@ -0,0 +1,25 @@
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
+25
View File
@@ -0,0 +1,25 @@
[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
+30
View File
@@ -0,0 +1,30 @@
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.
@@ -0,0 +1,30 @@
[English](CODE_OF_CONDUCT.md) | 中文
# 贡献者行为准则
## 我们的承诺
作为成员、贡献者和领导者,我们承诺让每个人在社区中都能拥有无骚扰的参与体验,无论年龄、体型、残障与否、种族、性别特征、性别认同与表达、经验水平、教育程度、社会经济地位、国籍、外貌、宗教或性取向。
## 我们的标准
有助于营造积极环境的行为:
- 使用友好和包容的语言
- 尊重不同的观点和经验
- 优雅地接受建设性批评
- 关注对社区最有利的事情
不可接受的行为:
- 挑衅、侮辱或贬损性评论,以及人身或政治攻击
- 公开或私下骚扰
- 未经明确许可发布他人的私人信息
## 执行
如遇到滥用、骚扰或其他不可接受的行为,可通过提交 Issue 或直接联系维护者进行举报。
## 归属
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) 2.1 版。
+55
View File
@@ -0,0 +1,55 @@
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
+55
View File
@@ -0,0 +1,55 @@
[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 Serverstdio 传输)
```
## 规范
- 提交前确保 `npx tsc --noEmit` 通过
- 如涉及界面文案,需同时更新 `src/client/i18n/` 中的中英文文件
+21
View File
@@ -0,0 +1,21 @@
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.
+317
View File
@@ -0,0 +1,317 @@
<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>
---
![BugPack Demo](https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/demo.gif)
---
## 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!**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/W7W51W5EN5)
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="Alipay">&nbsp;&nbsp;&nbsp;&nbsp;<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="WeChat Pay">
</div>
+310
View File
@@ -0,0 +1,310 @@
<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 Demo](https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/demo.gif)
---
## 什么是 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**
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/W7W51W5EN5)
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="支付宝">&nbsp;&nbsp;&nbsp;&nbsp;<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="微信支付">
</div>
+26
View File
@@ -0,0 +1,26 @@
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 |
+26
View File
@@ -0,0 +1,26 @@
[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.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+14
View File
@@ -0,0 +1,14 @@
#!/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)
}
+33
View File
@@ -0,0 +1,33 @@
<!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>
+8061
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
{
"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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+3
View File
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,53 @@
---
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"
}
}
```
@@ -0,0 +1,42 @@
---
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"
}
]
}
```
@@ -0,0 +1,57 @@
---
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"
}
]
}
}
```
+83
View File
@@ -0,0 +1,83 @@
---
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" }
```
+7
View File
@@ -0,0 +1,7 @@
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