From 0ff5a02155ae1cfad1babc5a7ab99de86ec21bfe Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 16 May 2026 23:57:30 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20=E2=80=94=20=E5=88=B0=E6=9C=9F=E6=8F=90=E9=86=92?= =?UTF-8?q?=E3=80=81=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E3=80=81Docker?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 26 ++ .dockerignore | 17 ++ .env.example | 2 +- .gitignore | 11 + Dockerfile | 21 ++ docker-compose.yml | 54 ++++ nginx-api.conf | 33 +++ public/admin/index.html | 470 ++++++++++++++++++++++++++++++++++ scripts/assign-userid.js | 33 +++ scripts/set-vip.js | 31 +++ src/middleware/adminAuth.js | 13 + src/models/User.js | 8 + src/routes/admin.js | 163 ++++++++++++ src/routes/auth.js | 13 +- src/routes/user.js | 339 +++++++++++++++++------- src/services/avatarService.js | 47 ++++ src/services/imageService.js | 65 +++++ src/services/ocrService.js | 243 ++++++++++++++++++ 18 files changed, 1487 insertions(+), 102 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx-api.conf create mode 100644 public/admin/index.html create mode 100644 scripts/assign-userid.js create mode 100644 scripts/set-vip.js create mode 100644 src/middleware/adminAuth.js create mode 100644 src/routes/admin.js create mode 100644 src/services/avatarService.js create mode 100644 src/services/imageService.js create mode 100644 src/services/ocrService.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4204780 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,26 @@ +{ + "permissions": { + "allow": [ + "Bash(node -e \"const s = require\\('sharp'\\); console.log\\('sharp version:', s.versions ? JSON.stringify\\(s.versions\\) : 'ok'\\); console.log\\('sharp loaded successfully'\\);\")", + "Bash(node -e \"const fs=require\\('fs'\\);const d=fs.readFileSync\\('/dev/stdin','utf8'\\);const j=JSON.parse\\(d\\);const s=j.packages['node_modules/sharp'];console.log\\(s?'sharp in lockfile version:'+\\(s.version||'unknown'\\):'sharp NOT in package-lock'\\);\")", + "Bash(node *)", + "Bash(ssh *)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\middleware\\\\adminAuth.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/middleware/adminAuth.js)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\routes\\\\admin.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/admin.js)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\public\\\\admin\\\\index.html\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/public/admin/index.html)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\src\\\\app.js\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/app.js)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\docker-compose.yml\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/docker-compose.yml)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no \"D:\\\\003_Project\\\\WeixinProject\\\\QuanYiXiaoZhuShou\\\\backend\\\\Dockerfile\" root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/Dockerfile)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/models/User.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/models/User.js)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/routes/auth.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/auth.js)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no src/routes/admin.js root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/src/routes/admin.js)", + "Bash(scp -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no public/admin/index.html root@8.136.137.59:/home/QuanYiXiaoZhuShou/docker/public/admin/index.html)", + "Bash(npm install *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git push *)", + "Bash(GIT_SSH_COMMAND='ssh -i \"D:\\\\003_Project\\\\小程序连接.pem\" -o StrictHostKeyChecking=no' git push *)", + "Bash(scp *)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a72de46 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.git +.gitignore +.env +.env.example +README.md +project.md +*.md +*.bat +*.zip +*.tar.gz +scripts/ +OwnershipEquity/ +nginx-api.conf +nul +backend-deploy.tar.gz +new-backend-deploy.zip diff --git a/.env.example b/.env.example index 7859eb5..3ef983a 100644 --- a/.env.example +++ b/.env.example @@ -21,7 +21,7 @@ BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4 # 服务器地址 -SERVER_URL=https://api.dxz99wyr.cn +SERVER_URL=https://api-miniapp.dxz99wyr.cn # 数据导出加密密钥(建议设置一个复杂的密钥) EXPORT_ENCRYPT_KEY=your_export_encrypt_key_here diff --git a/.gitignore b/.gitignore index 9862d02..5f9bb30 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,14 @@ build/ # MongoDB local data mongodb/ + +# Deploy archives +*.tar.gz +*.zip + +# Claude conversation logs +*-claude.txt + +# Junk files +nul +VIP:* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..07e5bf0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-slim + +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && apt-get install -y --no-install-recommends \ + libvips-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install --omit=dev && npm cache clean --force + +COPY src/ ./src/ +COPY public/ ./public/ + +RUN mkdir -p public/uploads/avatars public/avatars public/admin + +EXPOSE 3000 + +CMD ["node", "src/app.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..33bd5ac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +services: + app: + build: . + container_name: quanyixiaozhushou-app + restart: unless-stopped + ports: + - "3000:3000" + environment: + - PORT=3000 + - NODE_ENV=production + - WECHAT_APPID=wxa83262674846ca1a + - WECHAT_APPSECRET=365653aa1214a5523a6a0e7d793eec6a + - MONGODB_URI=mongodb://mongo:27017/quanyixiaozhushou + - JWT_SECRET=your_jwt_secret_key_here_change_in_production + - JWT_EXPIRES_IN=7d + - LOG_LEVEL=info + - BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT + - BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4 + - SERVER_URL=https://api-miniapp.dxz99wyr.cn + - EXPORT_ENCRYPT_KEY=QuanYiXiaoZhuShou_2026_Secret_Key + - ADMIN_KEY=quanyiAdmin2026 + volumes: + - uploads_data:/app/public/uploads + - ./public/avatars:/app/public/avatars + depends_on: + mongo: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + mongo: + image: mongo:7.0 + container_name: quanyixiaozhushou-mongo + restart: unless-stopped + ports: + - "27018:27017" + volumes: + - mongo_data:/data/db + environment: + - MONGO_INITDB_DATABASE=quanyixiaozhushou + healthcheck: + test: echo 'db.runCommand("ping").ok' | mongosh --quiet + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + uploads_data: + mongo_data: diff --git a/nginx-api.conf b/nginx-api.conf new file mode 100644 index 0000000..ff36c17 --- /dev/null +++ b/nginx-api.conf @@ -0,0 +1,33 @@ +server { + listen 443 ssl; + server_name api-miniapp.dxz99wyr.cn; + + ssl_certificate /ssl/cert.pem; + ssl_certificate_key /ssl/cret.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + client_max_body_size 10M; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /uploads/ { + alias /home/QuanYiXiaoZhuShou/Backend/public/uploads/; + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..801e3e7 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,470 @@ + + + + + +权益小助手 - 管理后台 + + + + + +
+ +
+ + + + + + + + + + diff --git a/scripts/assign-userid.js b/scripts/assign-userid.js new file mode 100644 index 0000000..c8be03e --- /dev/null +++ b/scripts/assign-userid.js @@ -0,0 +1,33 @@ +const mongoose = require('mongoose'); +require('dotenv').config(); + +async function assignUserIds() { + try { + await mongoose.connect(process.env.MONGODB_URI); + console.log('数据库已连接'); + + const db = mongoose.connection.db; + const users = await db.collection('users').find({}).toArray(); + console.log(`找到 ${users.length} 个用户`); + + for (let i = 0; i < users.length; i++) { + const user = users[i]; + const userId = (i + 1).toString().padStart(8, '0'); + + await db.collection('users').updateOne( + { _id: user._id }, + { $set: { userId } } + ); + + console.log(`已分配: ${user.nickname || '(空昵称)'} -> ${userId}`); + } + + console.log('全部分配完成!'); + process.exit(0); + } catch (err) { + console.error('错误:', err); + process.exit(1); + } +} + +assignUserIds(); diff --git a/scripts/set-vip.js b/scripts/set-vip.js new file mode 100644 index 0000000..5cc2857 --- /dev/null +++ b/scripts/set-vip.js @@ -0,0 +1,31 @@ +const mongoose = require('mongoose'); +require('dotenv').config(); + +async function setVip() { + try { + await mongoose.connect(process.env.MONGODB_URI); + console.log('数据库已连接'); + + const db = mongoose.connection.db; + const result = await db.collection('users').updateOne( + { userId: '00000001' }, + { $set: { isVip: true, vipExpireAt: new Date('2026-12-31') } } + ); + console.log('更新结果:', result.modifiedCount); + + const user = await db.collection('users').findOne({ userId: '00000001' }); + console.log('用户信息:', JSON.stringify({ + nickname: user.nickname, + userId: user.userId, + isVip: user.isVip, + vipExpireAt: user.vipExpireAt + }, null, 2)); + + process.exit(0); + } catch (err) { + console.error('错误:', err); + process.exit(1); + } +} + +setVip(); diff --git a/src/middleware/adminAuth.js b/src/middleware/adminAuth.js new file mode 100644 index 0000000..0bece15 --- /dev/null +++ b/src/middleware/adminAuth.js @@ -0,0 +1,13 @@ +const adminAuth = (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ success: false, error: '未提供管理密钥' }); + } + const token = authHeader.split(' ')[1]; + if (token !== process.env.ADMIN_KEY) { + return res.status(401).json({ success: false, error: '管理密钥无效' }); + } + next(); +}; + +module.exports = { adminAuth }; diff --git a/src/models/User.js b/src/models/User.js index 0f322e8..066b7ed 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -83,6 +83,14 @@ const userSchema = new mongoose.Schema({ platformCount: { type: Number, default: 0 + }, + loginDays: { + type: Number, + default: 1 + }, + lastLoginDay: { + type: String, + default: '' } }, { timestamps: true diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..36ea489 --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,163 @@ +const express = require('express'); +const router = express.Router(); +const User = require('../models/User'); +const { adminAuth } = require('../middleware/adminAuth'); + +router.post('/login', (req, res) => { + const { key } = req.body; + if (!key) { + return res.status(400).json({ success: false, error: '缺少管理密钥' }); + } + if (key !== process.env.ADMIN_KEY) { + return res.status(401).json({ success: false, error: '管理密钥无效' }); + } + res.json({ success: true, data: { message: '验证成功' } }); +}); + +router.get('/stats', adminAuth, async (req, res, next) => { + try { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const [totalUsers, activeUsers, bannedUsers, vipUsers, todayNewUsers] = await Promise.all([ + User.countDocuments(), + User.countDocuments({ status: 'active' }), + User.countDocuments({ status: 'banned' }), + User.countDocuments({ isVip: true }), + User.countDocuments({ createdAt: { $gte: today } }) + ]); + + res.json({ + success: true, + data: { + totalUsers, + activeUsers, + bannedUsers, + vipUsers, + todayNewUsers + } + }); + } catch (error) { + next(error); + } +}); + +router.get('/users', adminAuth, async (req, res, next) => { + try { + const { + page = 1, + limit = 20, + search, + status, + isVip, + sortBy = 'createdAt', + order = 'desc' + } = req.query; + + const query = {}; + + if (search) { + query.$or = [ + { userId: { $regex: search, $options: 'i' } }, + { nickname: { $regex: search, $options: 'i' } } + ]; + } + + if (status && ['active', 'inactive', 'banned'].includes(status)) { + query.status = status; + } + + if (isVip === 'true') { + query.isVip = true; + } else if (isVip === 'false') { + query.isVip = false; + } + + const sortOrder = order === 'asc' ? 1 : -1; + const sortField = ['userId', 'createdAt', 'lastLoginAt', 'nickname', 'ocrCount', 'platformCount', 'loginDays'].includes(sortBy) ? sortBy : 'createdAt'; + + const pageNum = Math.max(1, parseInt(page)); + const limitNum = Math.min(100, Math.max(1, parseInt(limit) || 20)); + const skip = (pageNum - 1) * limitNum; + + const [users, total] = await Promise.all([ + User.find(query) + .select('-openid -unionid -__v') + .sort({ [sortField]: sortOrder }) + .skip(skip) + .limit(limitNum) + .lean(), + User.countDocuments(query) + ]); + + res.json({ + success: true, + data: { + users, + pagination: { + page: pageNum, + limit: limitNum, + total, + pages: Math.ceil(total / limitNum) + } + } + }); + } catch (error) { + next(error); + } +}); + +router.get('/users/:id', adminAuth, async (req, res, next) => { + try { + const user = await User.findById(req.params.id).select('-__v').lean(); + + if (!user) { + return res.status(404).json({ success: false, error: '用户不存在' }); + } + + res.json({ success: true, data: user }); + } catch (error) { + next(error); + } +}); + +router.put('/users/:id', adminAuth, async (req, res, next) => { + try { + const allowedUpdates = [ + 'nickname', 'phoneNumber', 'status', 'isVip', + 'vipExpireAt', 'ocrCount', 'ocrCountTotal', + 'platformLimit', 'platformCount' + ]; + + const updates = {}; + for (const key of allowedUpdates) { + if (req.body[key] !== undefined) { + updates[key] = req.body[key]; + } + } + + if (updates.status && !['active', 'inactive', 'banned'].includes(updates.status)) { + return res.status(400).json({ success: false, error: '无效的用户状态' }); + } + + if (updates.isVip === false && !req.body.vipExpireAt) { + updates.vipExpireAt = null; + } + + const user = await User.findByIdAndUpdate( + req.params.id, + { $set: updates }, + { new: true, runValidators: true } + ).select('-__v').lean(); + + if (!user) { + return res.status(404).json({ success: false, error: '用户不存在' }); + } + + res.json({ success: true, data: user }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/auth.js b/src/routes/auth.js index 3b8e1a1..1236c1e 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -76,11 +76,20 @@ router.post('/wechat-login', async (req, res, next) => { if (userInfo) { user.nickname = userInfo.nickName || user.nickname; user.avatarUrl = userInfo.avatarUrl || user.avatarUrl; - user.lastLoginAt = new Date(); - await user.save(); } } + { + 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, diff --git a/src/routes/user.js b/src/routes/user.js index b637ebd..3a498f7 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -1,99 +1,240 @@ -const express = require('express'); -const router = express.Router(); -const { auth } = require('../middleware/auth'); -const User = require('../models/User'); -const { downloadAndSaveAvatar } = require('../services/avatarService'); - -router.get('/profile', auth, async (req, res, next) => { - try { - const user = await User.findById(req.user._id) - .select('userId nickname avatarUrl status isVip vipExpireAt ocrCount ocrCountTotal platformLimit platformCount lastLoginAt'); - - 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, - isVip: user.isVip, - vipExpireAt: user.vipExpireAt, - ocrCount: user.ocrCount, - ocrCountTotal: user.ocrCountTotal, - platformLimit: user.platformLimit, - platformCount: user.platformCount, - lastLoginAt: user.lastLoginAt - } - }); - } 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.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); - } -}); - -module.exports = router; +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; diff --git a/src/services/avatarService.js b/src/services/avatarService.js new file mode 100644 index 0000000..bc0009f --- /dev/null +++ b/src/services/avatarService.js @@ -0,0 +1,47 @@ +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const sharp = require('sharp'); + +const AVATAR_DIR = path.join(process.cwd(), 'public', 'avatars'); + +if (!fs.existsSync(AVATAR_DIR)) { + fs.mkdirSync(AVATAR_DIR, { recursive: true }); +} + +async function downloadAndSaveAvatar(avatarUrl) { + if (!avatarUrl || !avatarUrl.startsWith('http')) { + return null; + } + + try { + const response = await axios.get(avatarUrl, { + responseType: 'arraybuffer', + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + const filename = `${crypto.randomUUID()}.jpg`; + const filepath = path.join(AVATAR_DIR, filename); + + const compressed = await sharp(response.data) + .resize(400, 400, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 80, mozjpeg: true }) + .toBuffer(); + + fs.writeFileSync(filepath, compressed); + + const serverUrl = process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn'; + return `${serverUrl}/avatars/${filename}`; + } catch (error) { + console.error('下载头像失败:', error.message); + return null; + } +} + +module.exports = { + downloadAndSaveAvatar +}; diff --git a/src/services/imageService.js b/src/services/imageService.js new file mode 100644 index 0000000..234c436 --- /dev/null +++ b/src/services/imageService.js @@ -0,0 +1,65 @@ +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs'); + +const AVATAR_MAX_SIZE = 400; +const AVATAR_QUALITY = 80; + +async function compressAvatar(inputPath, outputPath) { + try { + const exists = fs.existsSync(inputPath); + const stats = exists ? fs.statSync(inputPath) : null; + console.log('[图像压缩] 输入文件:', inputPath, '存在:', exists, '大小:', stats?.size, '字节'); + + const metadata = await sharp(inputPath).metadata(); + console.log('[图像压缩] 原图尺寸:', metadata.width, 'x', metadata.height, '格式:', metadata.format); + + if (metadata.width <= AVATAR_MAX_SIZE && metadata.height <= AVATAR_MAX_SIZE && metadata.format === 'jpeg') { + console.log('[图像压缩] 跳过压缩(已满足要求)'); + return inputPath; + } + + const finalOutputPath = outputPath || inputPath; + + if (finalOutputPath === inputPath) { + const tempPath = inputPath + '.tmp'; + await sharp(inputPath) + .resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: AVATAR_QUALITY, mozjpeg: true }) + .toFile(tempPath); + + fs.unlinkSync(inputPath); + fs.renameSync(tempPath, inputPath); + return inputPath; + } + + await sharp(inputPath) + .resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: AVATAR_QUALITY, mozjpeg: true }) + .toFile(finalOutputPath); + + return finalOutputPath; + } catch (error) { + console.error('图像压缩失败:', error.message); + return inputPath; + } +} + +async function compressAvatarBuffer(buffer) { + try { + const compressed = await sharp(buffer) + .resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: AVATAR_QUALITY, mozjpeg: true }) + .toBuffer(); + + return compressed; + } catch (error) { + console.error('图像缓冲区压缩失败:', error.message); + return buffer; + } +} + +module.exports = { + compressAvatar, + compressAvatarBuffer +}; diff --git a/src/services/ocrService.js b/src/services/ocrService.js new file mode 100644 index 0000000..2da4069 --- /dev/null +++ b/src/services/ocrService.js @@ -0,0 +1,243 @@ +const axios = require('axios'); + +const BAIDU_OCR_API = { + token: 'https://aip.baidubce.com/oauth/2.0/token', + generalBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic', + accurateBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic' +}; + +let accessTokenCache = { + token: null, + expireAt: 0 +}; + +async function getAccessToken() { + const now = Date.now(); + if (accessTokenCache.token && accessTokenCache.expireAt > now + 60000) { + return accessTokenCache.token; + } + + const apiKey = process.env.BAIDU_OCR_API_KEY; + const secretKey = process.env.BAIDU_OCR_SECRET_KEY; + + if (!apiKey || !secretKey) { + throw new Error('百度OCR配置缺失:请检查 BAIDU_OCR_API_KEY 和 BAIDU_OCR_SECRET_KEY'); + } + + const response = await axios.post(BAIDU_OCR_API.token, null, { + params: { + grant_type: 'client_credentials', + client_id: apiKey, + client_secret: secretKey + }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + }); + + const { access_token, expires_in } = response.data; + + if (!access_token) { + throw new Error(`获取百度OCR Token失败: ${JSON.stringify(response.data)}`); + } + + accessTokenCache = { + token: access_token, + expireAt: now + (expires_in * 1000) + }; + + return access_token; +} + +async function recognizeText(imageBase64, options = {}) { + const accessToken = await getAccessToken(); + + const url = `${BAIDU_OCR_API.generalBasic}?access_token=${accessToken}`; + + const params = new URLSearchParams(); + params.append('image', imageBase64); + + if (options.language_type) { + params.append('language_type', options.language_type); + } + + const response = await axios.post(url, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + return response.data; +} + +async function recognizeTextAccurate(imageBase64, options = {}) { + const accessToken = await getAccessToken(); + + const url = `${BAIDU_OCR_API.accurateBasic}?access_token=${accessToken}`; + + const params = new URLSearchParams(); + params.append('image', imageBase64); + + if (options.language_type) { + params.append('language_type', options.language_type); + } + + const response = await axios.post(url, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + return response.data; +} + +function getDefaultPrice(type, times) { + const prices = { + year: '120', + halfYear: '60', + quarter: '30', + month: '10' + }; + + if (type === 'times') { + const timesCount = parseInt(times) || 1; + return String(timesCount * 10); + } + + return prices[type] || '10'; +} + +function extractMembershipInfo(ocrResult) { + if (!ocrResult || !ocrResult.words_result || ocrResult.words_result.length === 0) { + return []; + } + + const text = ocrResult.words_result.map(w => w.words).join('\n'); + const lines = ocrResult.words_result.map(w => w.words); + + const platformKeywords = { + '淘宝': ['淘宝', 'taobao', '88vip', '88VIP'], + '京东': ['京东', 'jd', 'JD', 'plus', 'PLUS'], + '拼多多': ['拼多多', 'pdd', 'PDD'], + '美团': ['美团', 'meituan'], + '饿了么': ['饿了么', 'eleme', 'ele.me'], + '抖音': ['抖音', 'douyin', 'tiktok'], + '快手': ['快手', 'kuaishou'], + '网易云音乐': ['网易云', 'netease', '163'], + 'QQ音乐': ['QQ音乐', 'qq音乐'], + '优酷': ['优酷', 'youku'], + '爱奇艺': ['爱奇艺', 'iqiyi'], + '腾讯视频': ['腾讯视频', 'v.qq'], + '哔哩哔哩': ['哔哩哔哩', 'bilibili', 'B站'], + '喜马拉雅': ['喜马拉雅', 'ximalaya'], + '知乎': ['知乎', 'zhihu'], + '百度网盘': ['百度网盘', '百度云'] + }; + + let platform = null; + for (const [pName, keywords] of Object.entries(platformKeywords)) { + for (const keyword of keywords) { + if (text.toLowerCase().includes(keyword.toLowerCase())) { + platform = pName; + break; + } + } + if (platform) break; + } + + const typePatterns = [ + { patterns: [/年卡/, /年度会员/, /\d+年/], type: 'year' }, + { patterns: [/半年卡/, /半年会员/, /6个月/], type: 'halfYear' }, + { patterns: [/季卡/, /季度会员/, /3个月/], type: 'quarter' }, + { patterns: [/月卡/, /月度会员/, /1个月/], type: 'month' }, + { patterns: [/次卡/, /按次数/], type: 'times' } + ]; + + let detectedType = 'month'; + for (const { patterns, type } of typePatterns) { + for (const pattern of patterns) { + if (pattern.test(text)) { + detectedType = type; + break; + } + } + if (detectedType !== 'month') break; + } + + const datePatterns = [ + /(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/, + /(\d{4})(\d{2})(\d{2})/, + /(\d{2})[年/-](\d{1,2})[月/-](\d{1,2})/, + /有效期[至到::]\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/, + /到期[时间日]:?\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/, + /(\d{4})\.(\d{1,2})\.(\d{1,2})/ + ]; + + let expireDate = '9999-12-31'; + for (const pattern of datePatterns) { + const match = text.match(pattern); + if (match) { + let year = match[1]; + const month = match[2].padStart(2, '0'); + const day = match[3].padStart(2, '0'); + + if (year.length === 2) { + year = '20' + year; + } + + expireDate = `${year}-${month}-${day}`; + break; + } + } + + const benefitKeywords = [ + '优酷', '网易云', 'QQ音乐', '酷狗', '酷我', + '爱奇艺', '腾讯视频', '芒果TV', '哔哩哔哩', + '饿了么', '美团', '高德打车', '滴滴', + '夸克', '百度网盘', '迅雷', + '喜马拉雅', '知乎', '微博', + '淘票票', '飞猪', '希尔顿', '万豪', + '视频会员', '超级吃货卡', '天猫超市', '天猫国际', + '阿里健康', '专属客服', '省钱卡', '网盘会员', + '打车会员', '金卡', '皮肤装扮', '每日领券', + '出行礼遇', '专享立减', '游戏特权' + ]; + + const benefits = []; + for (const line of lines) { + for (const keyword of benefitKeywords) { + if (line.includes(keyword)) { + const existing = benefits.find(b => b.name === keyword); + if (!existing) { + benefits.push({ + name: keyword, + type: detectedType, + times: detectedType === 'times' ? null : null, + price: getDefaultPrice(detectedType, null), + expireDate: expireDate + }); + } + } + } + } + + if (benefits.length === 0 && platform) { + benefits.push({ + name: platform, + type: detectedType, + times: null, + price: getDefaultPrice(detectedType, null), + expireDate: expireDate + }); + } + + return benefits; +} + +module.exports = { + getAccessToken, + recognizeText, + recognizeTextAccurate, + extractMembershipInfo +};