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 @@
+
+
+
+
+
+权益小助手 - 管理后台
+
+
+
+
+
+
+
+
权益小助手
+
管理后台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | userId |
+ 昵称 |
+ 状态 | VIP |
+ OCR |
+ 平台 |
+ 最后登录 |
+ 活跃天数 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+};