init: 小程序后台 — 到期提醒、定时任务、Docker部署配置
This commit is contained in:
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+11
-2
@@ -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,
|
||||
|
||||
+240
-99
@@ -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;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const AVATAR_DIR = path.join(process.cwd(), 'public', 'avatars');
|
||||
|
||||
if (!fs.existsSync(AVATAR_DIR)) {
|
||||
fs.mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function downloadAndSaveAvatar(avatarUrl) {
|
||||
if (!avatarUrl || !avatarUrl.startsWith('http')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(avatarUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
const filename = `${crypto.randomUUID()}.jpg`;
|
||||
const filepath = path.join(AVATAR_DIR, filename);
|
||||
|
||||
const compressed = await sharp(response.data)
|
||||
.resize(400, 400, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 80, mozjpeg: true })
|
||||
.toBuffer();
|
||||
|
||||
fs.writeFileSync(filepath, compressed);
|
||||
|
||||
const serverUrl = process.env.SERVER_URL || 'https://api-miniapp.dxz99wyr.cn';
|
||||
return `${serverUrl}/avatars/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('下载头像失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadAndSaveAvatar
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const AVATAR_MAX_SIZE = 400;
|
||||
const AVATAR_QUALITY = 80;
|
||||
|
||||
async function compressAvatar(inputPath, outputPath) {
|
||||
try {
|
||||
const exists = fs.existsSync(inputPath);
|
||||
const stats = exists ? fs.statSync(inputPath) : null;
|
||||
console.log('[图像压缩] 输入文件:', inputPath, '存在:', exists, '大小:', stats?.size, '字节');
|
||||
|
||||
const metadata = await sharp(inputPath).metadata();
|
||||
console.log('[图像压缩] 原图尺寸:', metadata.width, 'x', metadata.height, '格式:', metadata.format);
|
||||
|
||||
if (metadata.width <= AVATAR_MAX_SIZE && metadata.height <= AVATAR_MAX_SIZE && metadata.format === 'jpeg') {
|
||||
console.log('[图像压缩] 跳过压缩(已满足要求)');
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
const finalOutputPath = outputPath || inputPath;
|
||||
|
||||
if (finalOutputPath === inputPath) {
|
||||
const tempPath = inputPath + '.tmp';
|
||||
await sharp(inputPath)
|
||||
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||
.toFile(tempPath);
|
||||
|
||||
fs.unlinkSync(inputPath);
|
||||
fs.renameSync(tempPath, inputPath);
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
await sharp(inputPath)
|
||||
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||
.toFile(finalOutputPath);
|
||||
|
||||
return finalOutputPath;
|
||||
} catch (error) {
|
||||
console.error('图像压缩失败:', error.message);
|
||||
return inputPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function compressAvatarBuffer(buffer) {
|
||||
try {
|
||||
const compressed = await sharp(buffer)
|
||||
.resize(AVATAR_MAX_SIZE, AVATAR_MAX_SIZE, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: AVATAR_QUALITY, mozjpeg: true })
|
||||
.toBuffer();
|
||||
|
||||
return compressed;
|
||||
} catch (error) {
|
||||
console.error('图像缓冲区压缩失败:', error.message);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compressAvatar,
|
||||
compressAvatarBuffer
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
const axios = require('axios');
|
||||
|
||||
const BAIDU_OCR_API = {
|
||||
token: 'https://aip.baidubce.com/oauth/2.0/token',
|
||||
generalBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic',
|
||||
accurateBasic: 'https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic'
|
||||
};
|
||||
|
||||
let accessTokenCache = {
|
||||
token: null,
|
||||
expireAt: 0
|
||||
};
|
||||
|
||||
async function getAccessToken() {
|
||||
const now = Date.now();
|
||||
if (accessTokenCache.token && accessTokenCache.expireAt > now + 60000) {
|
||||
return accessTokenCache.token;
|
||||
}
|
||||
|
||||
const apiKey = process.env.BAIDU_OCR_API_KEY;
|
||||
const secretKey = process.env.BAIDU_OCR_SECRET_KEY;
|
||||
|
||||
if (!apiKey || !secretKey) {
|
||||
throw new Error('百度OCR配置缺失:请检查 BAIDU_OCR_API_KEY 和 BAIDU_OCR_SECRET_KEY');
|
||||
}
|
||||
|
||||
const response = await axios.post(BAIDU_OCR_API.token, null, {
|
||||
params: {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: apiKey,
|
||||
client_secret: secretKey
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const { access_token, expires_in } = response.data;
|
||||
|
||||
if (!access_token) {
|
||||
throw new Error(`获取百度OCR Token失败: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
accessTokenCache = {
|
||||
token: access_token,
|
||||
expireAt: now + (expires_in * 1000)
|
||||
};
|
||||
|
||||
return access_token;
|
||||
}
|
||||
|
||||
async function recognizeText(imageBase64, options = {}) {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const url = `${BAIDU_OCR_API.generalBasic}?access_token=${accessToken}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('image', imageBase64);
|
||||
|
||||
if (options.language_type) {
|
||||
params.append('language_type', options.language_type);
|
||||
}
|
||||
|
||||
const response = await axios.post(url, params.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function recognizeTextAccurate(imageBase64, options = {}) {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
const url = `${BAIDU_OCR_API.accurateBasic}?access_token=${accessToken}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('image', imageBase64);
|
||||
|
||||
if (options.language_type) {
|
||||
params.append('language_type', options.language_type);
|
||||
}
|
||||
|
||||
const response = await axios.post(url, params.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function getDefaultPrice(type, times) {
|
||||
const prices = {
|
||||
year: '120',
|
||||
halfYear: '60',
|
||||
quarter: '30',
|
||||
month: '10'
|
||||
};
|
||||
|
||||
if (type === 'times') {
|
||||
const timesCount = parseInt(times) || 1;
|
||||
return String(timesCount * 10);
|
||||
}
|
||||
|
||||
return prices[type] || '10';
|
||||
}
|
||||
|
||||
function extractMembershipInfo(ocrResult) {
|
||||
if (!ocrResult || !ocrResult.words_result || ocrResult.words_result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const text = ocrResult.words_result.map(w => w.words).join('\n');
|
||||
const lines = ocrResult.words_result.map(w => w.words);
|
||||
|
||||
const platformKeywords = {
|
||||
'淘宝': ['淘宝', 'taobao', '88vip', '88VIP'],
|
||||
'京东': ['京东', 'jd', 'JD', 'plus', 'PLUS'],
|
||||
'拼多多': ['拼多多', 'pdd', 'PDD'],
|
||||
'美团': ['美团', 'meituan'],
|
||||
'饿了么': ['饿了么', 'eleme', 'ele.me'],
|
||||
'抖音': ['抖音', 'douyin', 'tiktok'],
|
||||
'快手': ['快手', 'kuaishou'],
|
||||
'网易云音乐': ['网易云', 'netease', '163'],
|
||||
'QQ音乐': ['QQ音乐', 'qq音乐'],
|
||||
'优酷': ['优酷', 'youku'],
|
||||
'爱奇艺': ['爱奇艺', 'iqiyi'],
|
||||
'腾讯视频': ['腾讯视频', 'v.qq'],
|
||||
'哔哩哔哩': ['哔哩哔哩', 'bilibili', 'B站'],
|
||||
'喜马拉雅': ['喜马拉雅', 'ximalaya'],
|
||||
'知乎': ['知乎', 'zhihu'],
|
||||
'百度网盘': ['百度网盘', '百度云']
|
||||
};
|
||||
|
||||
let platform = null;
|
||||
for (const [pName, keywords] of Object.entries(platformKeywords)) {
|
||||
for (const keyword of keywords) {
|
||||
if (text.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
platform = pName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (platform) break;
|
||||
}
|
||||
|
||||
const typePatterns = [
|
||||
{ patterns: [/年卡/, /年度会员/, /\d+年/], type: 'year' },
|
||||
{ patterns: [/半年卡/, /半年会员/, /6个月/], type: 'halfYear' },
|
||||
{ patterns: [/季卡/, /季度会员/, /3个月/], type: 'quarter' },
|
||||
{ patterns: [/月卡/, /月度会员/, /1个月/], type: 'month' },
|
||||
{ patterns: [/次卡/, /按次数/], type: 'times' }
|
||||
];
|
||||
|
||||
let detectedType = 'month';
|
||||
for (const { patterns, type } of typePatterns) {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(text)) {
|
||||
detectedType = type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (detectedType !== 'month') break;
|
||||
}
|
||||
|
||||
const datePatterns = [
|
||||
/(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/(\d{4})(\d{2})(\d{2})/,
|
||||
/(\d{2})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/有效期[至到::]\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/到期[时间日]:?\s*(\d{4})[年/-](\d{1,2})[月/-](\d{1,2})/,
|
||||
/(\d{4})\.(\d{1,2})\.(\d{1,2})/
|
||||
];
|
||||
|
||||
let expireDate = '9999-12-31';
|
||||
for (const pattern of datePatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
let year = match[1];
|
||||
const month = match[2].padStart(2, '0');
|
||||
const day = match[3].padStart(2, '0');
|
||||
|
||||
if (year.length === 2) {
|
||||
year = '20' + year;
|
||||
}
|
||||
|
||||
expireDate = `${year}-${month}-${day}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const benefitKeywords = [
|
||||
'优酷', '网易云', 'QQ音乐', '酷狗', '酷我',
|
||||
'爱奇艺', '腾讯视频', '芒果TV', '哔哩哔哩',
|
||||
'饿了么', '美团', '高德打车', '滴滴',
|
||||
'夸克', '百度网盘', '迅雷',
|
||||
'喜马拉雅', '知乎', '微博',
|
||||
'淘票票', '飞猪', '希尔顿', '万豪',
|
||||
'视频会员', '超级吃货卡', '天猫超市', '天猫国际',
|
||||
'阿里健康', '专属客服', '省钱卡', '网盘会员',
|
||||
'打车会员', '金卡', '皮肤装扮', '每日领券',
|
||||
'出行礼遇', '专享立减', '游戏特权'
|
||||
];
|
||||
|
||||
const benefits = [];
|
||||
for (const line of lines) {
|
||||
for (const keyword of benefitKeywords) {
|
||||
if (line.includes(keyword)) {
|
||||
const existing = benefits.find(b => b.name === keyword);
|
||||
if (!existing) {
|
||||
benefits.push({
|
||||
name: keyword,
|
||||
type: detectedType,
|
||||
times: detectedType === 'times' ? null : null,
|
||||
price: getDefaultPrice(detectedType, null),
|
||||
expireDate: expireDate
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (benefits.length === 0 && platform) {
|
||||
benefits.push({
|
||||
name: platform,
|
||||
type: detectedType,
|
||||
times: null,
|
||||
price: getDefaultPrice(detectedType, null),
|
||||
expireDate: expireDate
|
||||
});
|
||||
}
|
||||
|
||||
return benefits;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccessToken,
|
||||
recognizeText,
|
||||
recognizeTextAccurate,
|
||||
extractMembershipInfo
|
||||
};
|
||||
Reference in New Issue
Block a user