diff --git a/.env.example b/.env.example index 55cf26d..7859eb5 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,13 @@ JWT_EXPIRES_IN=7d # 日志配置 LOG_LEVEL=info + +# 百度OCR配置 +BAIDU_OCR_API_KEY=IfYLOLzL6X60h5UOdnkX6OmT +BAIDU_OCR_SECRET_KEY=wGXbp6DwazDghJ1EXtjAT7XAFwJLqVD4 + +# 服务器地址 +SERVER_URL=https://api.dxz99wyr.cn + +# 数据导出加密密钥(建议设置一个复杂的密钥) +EXPORT_ENCRYPT_KEY=your_export_encrypt_key_here diff --git a/src/models/EquityDetail.js b/src/models/EquityDetail.js new file mode 100644 index 0000000..6c1b4a1 --- /dev/null +++ b/src/models/EquityDetail.js @@ -0,0 +1,72 @@ +const mongoose = require('mongoose'); + +const equityDetailSchema = new mongoose.Schema({ + platform: { + type: String, + required: true, + trim: true, + unique: true + }, + platformIcon: { + type: String, + default: '' + }, + platformColor: { + type: String, + default: '#1890ff' + }, + type: { + type: String, + required: true, + trim: true + }, + typeLabel: { + type: String, + default: '' + }, + summary: { + type: String, + default: '' + }, + summaryGenerated: { + type: Boolean, + default: false + }, + summaryApproved: { + type: Boolean, + default: false + }, + detail: { + type: String, + default: '' + }, + detailImages: [{ + type: String + }], + benefits: [{ + name: { + type: String, + required: true + }, + description: { + type: String, + default: '' + }, + icon: { + type: String, + default: '' + } + }], + status: { + type: String, + enum: ['active', 'inactive'], + default: 'active' + } +}, { + timestamps: true +}); + +equityDetailSchema.index({ platform: 1 }); +equityDetailSchema.index({ status: 1 }); + +module.exports = mongoose.model('EquityDetail', equityDetailSchema); diff --git a/src/models/PlatformPreset.js b/src/models/PlatformPreset.js new file mode 100644 index 0000000..5977d2a --- /dev/null +++ b/src/models/PlatformPreset.js @@ -0,0 +1,76 @@ +const mongoose = require('mongoose'); + +const benefitSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + trim: true + }, + type: { + type: String, + default: 'year' + }, + typeLabel: { + type: String, + default: '年卡' + }, + price: { + type: Number, + default: 0 + }, + description: { + type: String, + default: '' + } +}, { _id: true }); + +const platformPresetSchema = new mongoose.Schema({ + platform: { + type: String, + required: true, + trim: true + }, + platformIcon: { + type: String, + default: '' + }, + platformColor: { + type: String, + default: '#1890ff' + }, + type: { + type: String, + required: true, + trim: true + }, + typeLabel: { + type: String, + default: '' + }, + description: { + type: String, + default: '' + }, + benefits: [benefitSchema], + isHot: { + type: Boolean, + default: false + }, + sortOrder: { + type: Number, + default: 0 + }, + status: { + type: String, + enum: ['active', 'inactive'], + default: 'active' + } +}, { + timestamps: true +}); + +platformPresetSchema.index({ platform: 1 }); +platformPresetSchema.index({ isHot: 1, sortOrder: 1 }); +platformPresetSchema.index({ status: 1 }); + +module.exports = mongoose.model('PlatformPreset', platformPresetSchema); diff --git a/src/models/User.js b/src/models/User.js index a3a81b8..0129761 100644 --- a/src/models/User.js +++ b/src/models/User.js @@ -50,6 +50,34 @@ const userSchema = new mongoose.Schema({ lastLoginAt: { type: Date, default: Date.now + }, + isVip: { + type: Boolean, + default: false + }, + vipExpireAt: { + type: Date, + default: null + }, + ocrCount: { + type: Number, + default: 10 + }, + ocrCountTotal: { + type: Number, + default: 10 + }, + ocrCountResetAt: { + type: Date, + default: Date.now + }, + platformLimit: { + type: Number, + default: 15 + }, + platformCount: { + type: Number, + default: 0 } }, { timestamps: true diff --git a/src/models/UserEquity.js b/src/models/UserEquity.js new file mode 100644 index 0000000..51fb8a5 --- /dev/null +++ b/src/models/UserEquity.js @@ -0,0 +1,160 @@ +const mongoose = require('mongoose'); + +const benefitSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + trim: true + }, + type: { + type: String, + required: true, + trim: true + }, + typeLabel: { + type: String, + default: '' + }, + expireDate: { + type: String, + default: null + }, + used: { + type: Boolean, + default: false + }, + usedTime: { + type: String, + default: null + }, + times: { + type: Number, + default: 0 + }, + usedTimes: { + type: Number, + default: 0 + }, + usedAmount: { + type: Number, + default: 0 + }, + totalAmount: { + type: Number, + default: 0 + }, + rechargeAmount: { + type: Number, + default: 0 + }, + price: { + type: Number, + default: 0 + }, + currency: { + type: String, + default: '¥' + }, + icon: { + type: String, + default: '' + }, + autoRenew: { + type: Boolean, + default: false + }, + autoRenewCycle: { + type: String, + default: '' + }, + renewReminderDismissed: { + type: Boolean, + default: false + } +}, { _id: true }); + +const userEquitySchema = new mongoose.Schema({ + platform: { + type: String, + required: true, + trim: true + }, + type: { + type: String, + required: true, + trim: true + }, + platformType: { + type: String, + default: 'online' + }, + expireDate: { + type: String, + required: true + }, + price: { + type: Number, + default: 0 + }, + brandIcon: { + type: String, + default: '' + }, + brandIconImage: { + type: String, + default: '' + }, + brandColor: { + type: String, + default: '' + }, + benefits: [benefitSchema], + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + status: { + type: String, + enum: ['active', 'used', 'expired'], + default: 'active' + }, + note: { + type: String, + default: '' + }, + hasUsedBenefit: { + type: Boolean, + default: false + }, + createTime: { + type: String, + default: () => new Date().toISOString() + }, + updateTime: { + type: String, + default: () => new Date().toISOString() + }, + syncedAt: { + type: String, + default: () => new Date().toISOString() + } +}, { + timestamps: false +}); + +userEquitySchema.index({ owner: 1, status: 1 }); +userEquitySchema.index({ platform: 1 }); +userEquitySchema.index({ expireDate: 1 }); + +userEquitySchema.pre('save', function(next) { + this.updateTime = new Date().toISOString(); + next(); +}); + +userEquitySchema.pre('findOneAndUpdate', function(next) { + this.set({ updateTime: new Date().toISOString() }); + next(); +}); + +module.exports = mongoose.model('UserEquity', userEquitySchema); diff --git a/src/models/UserSubscription.js b/src/models/UserSubscription.js new file mode 100644 index 0000000..1636549 --- /dev/null +++ b/src/models/UserSubscription.js @@ -0,0 +1,47 @@ +const mongoose = require('mongoose'); + +const userSubscriptionSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + openid: { + type: String, + required: true, + index: true + }, + templateId: { + type: String, + required: true, + index: true + }, + scene: { + type: String, + default: '' + }, + status: { + type: String, + enum: ['active', 'expired', 'used'], + default: 'active' + }, + subscribedAt: { + type: Date, + default: Date.now + }, + expiredAt: { + type: Date, + default: null + }, + usedAt: { + type: Date, + default: null + } +}, { + timestamps: true +}); + +userSubscriptionSchema.index({ userId: 1, templateId: 1 }); + +module.exports = mongoose.model('UserSubscription', userSubscriptionSchema); diff --git a/src/models/VersionLog.js b/src/models/VersionLog.js new file mode 100644 index 0000000..6dc88c3 --- /dev/null +++ b/src/models/VersionLog.js @@ -0,0 +1,41 @@ +const mongoose = require('mongoose'); + +const versionLogSchema = new mongoose.Schema({ + version: { + type: String, + required: true, + trim: true + }, + versionName: { + type: String, + default: '' + }, + releaseDate: { + type: Date, + default: Date.now + }, + features: [{ + type: String + }], + fixes: [{ + type: String + }], + improvements: [{ + type: String + }], + isPublished: { + type: Boolean, + default: true + }, + sortOrder: { + type: Number, + default: 0 + } +}, { + timestamps: true +}); + +versionLogSchema.index({ version: 1 }); +versionLogSchema.index({ isPublished: 1, sortOrder: -1 }); + +module.exports = mongoose.model('VersionLog', versionLogSchema); diff --git a/src/routes/auth.js b/src/routes/auth.js index dc21540..13fcf05 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -75,7 +75,13 @@ router.post('/wechat-login', async (req, res, next) => { id: user._id, nickname: user.nickname, avatarUrl: user.avatarUrl, - status: user.status + status: user.status, + isVip: user.isVip, + vipExpireAt: user.vipExpireAt, + ocrCount: user.ocrCount, + ocrCountTotal: user.ocrCountTotal, + platformLimit: user.platformLimit, + platformCount: user.platformCount } } }); diff --git a/src/routes/equityDetail.js b/src/routes/equityDetail.js new file mode 100644 index 0000000..58a8917 --- /dev/null +++ b/src/routes/equityDetail.js @@ -0,0 +1,52 @@ +const express = require('express'); +const router = express.Router(); +const EquityDetail = require('../models/EquityDetail'); + +router.get('/', async (req, res, next) => { + try { + const { platform } = req.query; + const query = { status: 'active' }; + + if (platform) { + query.platform = platform; + } + + const details = await EquityDetail.find(query) + .sort({ platform: 1 }) + .lean(); + + res.json({ + success: true, + data: details + }); + } catch (error) { + next(error); + } +}); + +router.get('/:platform', async (req, res, next) => { + try { + const { platform } = req.params; + + const detail = await EquityDetail.findOne({ + platform, + status: 'active' + }).lean(); + + if (!detail) { + return res.status(404).json({ + success: false, + error: '权益详情不存在' + }); + } + + res.json({ + success: true, + data: detail + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/membership.js b/src/routes/membership.js new file mode 100644 index 0000000..98a290d --- /dev/null +++ b/src/routes/membership.js @@ -0,0 +1,79 @@ +const express = require('express'); +const router = express.Router(); +const { auth } = require('../middleware/auth'); +const User = require('../models/User'); +const UserEquity = require('../models/UserEquity'); + +router.get('/status', auth, async (req, res, next) => { + try { + const user = await User.findById(req.user._id); + + const now = new Date(); + const resetDate = new Date(user.ocrCountResetAt); + const isNewMonth = now.getFullYear() !== resetDate.getFullYear() || + now.getMonth() !== resetDate.getMonth(); + + if (isNewMonth) { + user.ocrCount = user.ocrCountTotal; + user.ocrCountResetAt = now; + await user.save(); + } + + const platformCount = await UserEquity.countDocuments({ owner: req.user._id }); + + res.json({ + success: true, + data: { + isVip: user.isVip, + vipExpireAt: user.vipExpireAt, + ocrCount: user.ocrCount, + ocrCountTotal: user.ocrCountTotal, + ocrCountResetAt: user.ocrCountResetAt, + platformLimit: user.platformLimit, + platformCount: platformCount, + remainingPlatforms: user.isVip ? -1 : Math.max(0, user.platformLimit - platformCount) + } + }); + } catch (error) { + next(error); + } +}); + +router.post('/upgrade', auth, async (req, res, next) => { + try { + const { duration = 1 } = req.body; + + const user = await User.findById(req.user._id); + + const now = new Date(); + let currentExpireAt = user.vipExpireAt ? new Date(user.vipExpireAt) : now; + + if (currentExpireAt < now) { + currentExpireAt = now; + } + + currentExpireAt.setMonth(currentExpireAt.getMonth() + duration); + + user.isVip = true; + user.vipExpireAt = currentExpireAt; + user.ocrCountTotal = 100; + user.ocrCount = 100; + user.platformLimit = -1; + await user.save(); + + res.json({ + success: true, + data: { + isVip: user.isVip, + vipExpireAt: user.vipExpireAt, + ocrCount: user.ocrCount, + ocrCountTotal: user.ocrCountTotal, + platformLimit: user.platformLimit + } + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/ocr.js b/src/routes/ocr.js new file mode 100644 index 0000000..18e1175 --- /dev/null +++ b/src/routes/ocr.js @@ -0,0 +1,105 @@ +const express = require('express'); +const router = express.Router(); +const { auth } = require('../middleware/auth'); +const User = require('../models/User'); +const { recognizeText, extractMembershipInfo } = require('../services/ocrService'); + +router.post('/recognize', auth, async (req, res, next) => { + try { + const { image } = req.body; + + if (!image) { + return res.status(400).json({ + success: false, + error: '缺少图片数据' + }); + } + + const user = await User.findById(req.user._id); + + const now = new Date(); + const resetDate = new Date(user.ocrCountResetAt); + const isNewMonth = now.getFullYear() !== resetDate.getFullYear() || + now.getMonth() !== resetDate.getMonth(); + + if (isNewMonth) { + user.ocrCount = user.ocrCountTotal; + user.ocrCountResetAt = now; + await user.save(); + } + + if (user.ocrCount <= 0) { + return res.status(403).json({ + success: false, + error: '本月OCR次数已用完,请升级会员获取更多次数', + data: { + ocrCount: 0, + ocrCountTotal: user.ocrCountTotal, + isVip: user.isVip + } + }); + } + + const imageBase64 = image.replace(/^data:image\/\w+;base64,/, ''); + + const ocrResult = await recognizeText(imageBase64, { + language_type: 'CHN_ENG' + }); + + if (ocrResult.error_code) { + return res.status(400).json({ + success: false, + error: `OCR识别失败: ${ocrResult.error_msg}`, + data: ocrResult + }); + } + + const extractedInfo = extractMembershipInfo(ocrResult); + + user.ocrCount -= 1; + await user.save(); + + res.json({ + success: true, + data: extractedInfo, + meta: { + ocrCount: user.ocrCount, + ocrCountTotal: user.ocrCountTotal, + isVip: user.isVip + } + }); + } catch (error) { + next(error); + } +}); + +router.get('/quota', auth, async (req, res, next) => { + try { + const user = await User.findById(req.user._id); + + const now = new Date(); + const resetDate = new Date(user.ocrCountResetAt); + const isNewMonth = now.getFullYear() !== resetDate.getFullYear() || + now.getMonth() !== resetDate.getMonth(); + + if (isNewMonth) { + user.ocrCount = user.ocrCountTotal; + user.ocrCountResetAt = now; + await user.save(); + } + + res.json({ + success: true, + data: { + ocrCount: user.ocrCount, + ocrCountTotal: user.ocrCountTotal, + isVip: user.isVip, + resetAt: user.ocrCountResetAt + } + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/preset.js b/src/routes/preset.js new file mode 100644 index 0000000..545e4fb --- /dev/null +++ b/src/routes/preset.js @@ -0,0 +1,103 @@ +const express = require('express'); +const router = express.Router(); +const { auth } = require('../middleware/auth'); +const PlatformPreset = require('../models/PlatformPreset'); +const UserEquity = require('../models/UserEquity'); +const User = require('../models/User'); + +router.get('/', async (req, res, next) => { + try { + const presets = await PlatformPreset.find({ status: 'active' }) + .sort({ isHot: -1, sortOrder: 1 }) + .lean(); + + res.json({ + success: true, + data: presets + }); + } catch (error) { + next(error); + } +}); + +router.get('/hot', async (req, res, next) => { + try { + const presets = await PlatformPreset.find({ status: 'active', isHot: true }) + .sort({ sortOrder: 1 }) + .lean(); + + res.json({ + success: true, + data: presets + }); + } catch (error) { + next(error); + } +}); + +router.post('/import/:id', auth, async (req, res, next) => { + try { + const { id } = req.params; + const { expireDate, price } = req.body; + + const preset = await PlatformPreset.findById(id); + + if (!preset) { + return res.status(404).json({ + success: false, + error: '预设平台不存在' + }); + } + + const user = await User.findById(req.user._id); + + if (!user.isVip) { + const userEquityCount = await UserEquity.countDocuments({ owner: req.user._id }); + if (userEquityCount >= user.platformLimit) { + return res.status(403).json({ + success: false, + error: '非会员最多只能添加15个平台,请升级会员' + }); + } + } + + if (!expireDate) { + return res.status(400).json({ + success: false, + error: '缺少到期时间' + }); + } + + const equityData = { + platform: preset.platform, + type: preset.type, + expireDate, + price: parseFloat(price) || preset.price || 0, + benefits: preset.benefits.map(b => ({ + name: b.name, + type: b.type, + typeLabel: b.typeLabel, + expireDate, + used: false, + usedTime: null + })), + owner: req.user._id, + status: 'active', + note: preset.description || '' + }; + + const equity = await UserEquity.create(equityData); + + user.platformCount = await UserEquity.countDocuments({ owner: req.user._id }); + await user.save(); + + res.status(201).json({ + success: true, + data: equity + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/settings.js b/src/routes/settings.js new file mode 100644 index 0000000..57a092e --- /dev/null +++ b/src/routes/settings.js @@ -0,0 +1,206 @@ +const express = require('express'); +const router = express.Router(); +const { auth } = require('../middleware/auth'); +const VersionLog = require('../models/VersionLog'); +const crypto = require('crypto'); + +const ALGORITHM = 'aes-256-gcm'; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; +const SALT_LENGTH = 64; +const TAG_LENGTH = 16; + +function getEncryptionKey() { + const envKey = process.env.EXPORT_ENCRYPT_KEY; + if (envKey) { + return crypto.scryptSync(envKey, 'salt', KEY_LENGTH); + } + return crypto.randomBytes(KEY_LENGTH); +} + +function encryptData(data) { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const salt = crypto.randomBytes(SALT_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const jsonData = JSON.stringify(data); + let encrypted = cipher.update(jsonData, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const tag = cipher.getAuthTag(); + + const result = { + salt: salt.toString('hex'), + iv: iv.toString('hex'), + tag: tag.toString('hex'), + data: encrypted + }; + + return Buffer.from(JSON.stringify(result)).toString('base64'); +} + +function decryptData(encryptedBase64) { + try { + const encryptedJson = Buffer.from(encryptedBase64, 'base64').toString('utf8'); + const encrypted = JSON.parse(encryptedJson); + + const key = getEncryptionKey(); + const iv = Buffer.from(encrypted.iv, 'hex'); + const tag = Buffer.from(encrypted.tag, 'hex'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + let decrypted = decipher.update(encrypted.data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); + } catch (error) { + throw new Error('数据解密失败,文件可能已损坏或被篡改'); + } +} + +router.get('/versions', async (req, res, next) => { + try { + const versions = await VersionLog.find({ isPublished: true }) + .sort({ sortOrder: -1, releaseDate: -1 }) + .lean(); + + res.json({ + success: true, + data: versions + }); + } catch (error) { + next(error); + } +}); + +router.get('/versions/:version', async (req, res, next) => { + try { + const { version } = req.params; + + const versionLog = await VersionLog.findOne({ version }).lean(); + + if (!versionLog) { + return res.status(404).json({ + success: false, + error: '版本记录不存在' + }); + } + + res.json({ + success: true, + data: versionLog + }); + } catch (error) { + next(error); + } +}); + +router.post('/export', auth, async (req, res, next) => { + try { + const UserEquity = require('../models/UserEquity'); + + const equities = await UserEquity.find({ owner: req.user._id }).lean(); + + const exportData = { + exportAt: new Date().toISOString(), + userId: req.user._id.toString(), + version: '1.0', + equities: equities + }; + + const encrypted = encryptData(exportData); + + res.json({ + success: true, + data: { + encryptedData: encrypted, + filename: `quanyi_backup_${new Date().getTime()}.json` + } + }); + } catch (error) { + next(error); + } +}); + +router.post('/import', auth, async (req, res, next) => { + try { + const { encryptedData } = req.body; + + if (!encryptedData) { + return res.status(400).json({ + success: false, + error: '缺少加密数据' + }); + } + + const decrypted = decryptData(encryptedData); + + if (!decrypted.equities || !Array.isArray(decrypted.equities)) { + return res.status(400).json({ + success: false, + error: '数据格式错误' + }); + } + + const UserEquity = require('../models/UserEquity'); + const User = require('../models/User'); + const user = await User.findById(req.user._id); + + const currentCount = await UserEquity.countDocuments({ owner: req.user._id }); + const importCount = decrypted.equities.length; + + if (!user.isVip && (currentCount + importCount) > user.platformLimit) { + return res.status(403).json({ + success: false, + error: `导入后平台数量将超过限制(${user.platformLimit}个),请升级会员` + }); + } + + const results = []; + const errors = []; + + for (const item of decrypted.equities) { + try { + const equityData = { + platform: item.platform, + type: item.type, + expireDate: item.expireDate, + price: item.price || 0, + benefits: item.benefits || [], + owner: req.user._id, + status: item.status || 'active', + note: item.note || '', + createTime: item.createTime || new Date().toISOString(), + updateTime: new Date().toISOString(), + syncedAt: new Date().toISOString() + }; + + const equity = await UserEquity.create(equityData); + results.push(equity); + } catch (itemError) { + errors.push({ item, error: itemError.message }); + } + } + + user.platformCount = await UserEquity.countDocuments({ owner: req.user._id }); + await user.save(); + + res.json({ + success: true, + data: { + imported: results.length, + failed: errors.length, + items: results, + errors: errors.length > 0 ? errors : undefined + } + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/subscribe.js b/src/routes/subscribe.js new file mode 100644 index 0000000..47541d4 --- /dev/null +++ b/src/routes/subscribe.js @@ -0,0 +1,116 @@ +const express = require('express'); +const UserSubscription = require('../models/UserSubscription'); +const WechatSubscribeService = require('../services/wechatSubscribeService'); +const { auth } = require('../middleware/auth'); + +const router = express.Router(); + +router.post('/save', auth, async (req, res, next) => { + try { + const { templateId, scene } = req.body; + + if (!templateId) { + return res.status(400).json({ + success: false, + error: 'templateId 不能为空' + }); + } + + const user = req.user; + + const existingSubscription = await UserSubscription.findOne({ + userId: user._id, + templateId, + status: 'active' + }); + + if (existingSubscription) { + return res.json({ success: true, message: '已存在有效的订阅' }); + } + + const subscription = new UserSubscription({ + userId: user._id, + openid: user.openid, + templateId, + scene: scene || '' + }); + + await subscription.save(); + + res.json({ success: true, message: '订阅保存成功' }); + } catch (error) { + next(error); + } +}); + +router.post('/version-update', auth, async (req, res, next) => { + try { + const { templateId, character_string1, thing3, thing6 } = req.body; + + if (!templateId) { + return res.status(400).json({ + success: false, + error: 'templateId 不能为空' + }); + } + if (!character_string1) { + return res.status(400).json({ + success: false, + error: 'character_string1 不能为空' + }); + } + if (!thing3) { + return res.status(400).json({ + success: false, + error: 'thing3 不能为空' + }); + } + + const user = req.user; + + const subscription = await UserSubscription.findOne({ + userId: user._id, + templateId, + status: 'active' + }); + + if (!subscription) { + return res.status(400).json({ + success: false, + error: '未找到有效的订阅,用户可能未订阅该模板' + }); + } + + const result = await WechatSubscribeService.sendVersionUpdateMessage({ + openid: user.openid, + templateId, + character_string1, + thing3, + thing6 + }); + + if (result.success) { + subscription.status = 'used'; + subscription.usedAt = new Date(); + await subscription.save(); + + res.json({ success: true, messageId: result.messageId, message: '消息发送成功' }); + } else { + if (result.errcode === 43101 || result.errcode === 40037) { + subscription.status = 'expired'; + subscription.expiredAt = new Date(); + await subscription.save(); + } + + res.status(400).json({ + success: false, + error: result.errmsg || '消息发送失败', + errcode: result.errcode + }); + } + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/routes/user.js b/src/routes/user.js index aa9b673..1fc4e52 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -2,6 +2,7 @@ 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 { @@ -27,6 +28,13 @@ router.put('/profile', auth, async (req, res, next) => { } }); + 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, diff --git a/src/routes/userEquity.js b/src/routes/userEquity.js new file mode 100644 index 0000000..06ada55 --- /dev/null +++ b/src/routes/userEquity.js @@ -0,0 +1,417 @@ +const express = require('express'); +const router = express.Router(); +const { auth } = require('../middleware/auth'); +const UserEquity = require('../models/UserEquity'); + +router.get('/', auth, async (req, res, next) => { + try { + const { + page = 1, + limit = 100, + status, + platform, + sortBy = 'createTime', + order = 'desc' + } = req.query; + + const query = { owner: req.user._id }; + + if (status) query.status = status; + if (platform) query.platform = platform; + + const sortOrder = order === 'asc' ? 1 : -1; + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [equities, total] = await Promise.all([ + UserEquity.find(query) + .sort({ [sortBy]: sortOrder }) + .skip(skip) + .limit(parseInt(limit)) + .lean(), + UserEquity.countDocuments(query) + ]); + + res.json({ + success: true, + data: { + list: equities, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + pages: Math.ceil(total / parseInt(limit)) + } + } + }); + } catch (error) { + next(error); + } +}); + +router.get('/all', auth, async (req, res, next) => { + try { + const { since } = req.query; + const query = { owner: req.user._id }; + + if (since) { + query.updateTime = { $gt: since }; + } + + const equities = await UserEquity.find(query) + .sort({ updateTime: -1 }) + .lean(); + + res.json({ + success: true, + data: { + list: equities, + count: equities.length + } + }); + } catch (error) { + next(error); + } +}); + +router.get('/list', auth, async (req, res, next) => { + try { + const equities = await UserEquity.find({ owner: req.user._id }) + .sort({ createTime: -1 }) + .lean(); + + res.json({ + success: true, + data: { + list: equities, + count: equities.length + } + }); + } catch (error) { + next(error); + } +}); + +router.get('/:id', auth, async (req, res, next) => { + try { + const equity = await UserEquity.findOne({ + _id: req.params.id, + owner: req.user._id + }); + + if (!equity) { + return res.status(404).json({ + success: false, + error: '权益不存在' + }); + } + + res.json({ + success: true, + data: equity + }); + } catch (error) { + next(error); + } +}); + +router.post('/', auth, async (req, res, next) => { + try { + const { + platform, + type, + expireDate, + price, + benefits, + status, + note, + createTime, + updateTime, + platformType, + brandIcon, + brandIconImage, + brandColor, + hasUsedBenefit + } = req.body; + + if (!platform || !type || !expireDate) { + return res.status(400).json({ + success: false, + error: '缺少必填字段:平台名称、会员类型、到期时间' + }); + } + + const equityData = { + platform: platform.trim(), + type: type.trim(), + expireDate, + price: parseFloat(price) || 0, + benefits: benefits || [], + owner: req.user._id, + status: status || 'active', + note: note || '', + syncedAt: new Date().toISOString() + }; + + if (platformType !== undefined) equityData.platformType = platformType; + if (brandIcon !== undefined) equityData.brandIcon = brandIcon; + if (brandIconImage !== undefined) equityData.brandIconImage = brandIconImage; + if (brandColor !== undefined) equityData.brandColor = brandColor; + if (hasUsedBenefit !== undefined) equityData.hasUsedBenefit = hasUsedBenefit; + if (createTime) equityData.createTime = createTime; + if (updateTime) equityData.updateTime = updateTime; + + const equity = await UserEquity.create(equityData); + + res.status(201).json({ + success: true, + data: equity + }); + } catch (error) { + next(error); + } +}); + +router.post('/batch', auth, async (req, res, next) => { + try { + const { items } = req.body; + + if (!Array.isArray(items) || items.length === 0) { + return res.status(400).json({ + success: false, + error: '缺少数据或格式错误' + }); + } + + const results = []; + const errors = []; + + for (const item of items) { + try { + const { + platform, + type, + expireDate, + price, + benefits, + status, + note, + createTime, + updateTime, + platformType, + brandIcon, + brandIconImage, + brandColor, + hasUsedBenefit + } = item; + + if (!platform || !type || !expireDate) { + errors.push({ item, error: '缺少必填字段' }); + continue; + } + + const equityData = { + platform: platform.trim(), + type: type.trim(), + expireDate, + price: parseFloat(price) || 0, + benefits: benefits || [], + owner: req.user._id, + status: status || 'active', + note: note || '', + syncedAt: new Date().toISOString() + }; + + if (platformType !== undefined) equityData.platformType = platformType; + if (brandIcon !== undefined) equityData.brandIcon = brandIcon; + if (brandIconImage !== undefined) equityData.brandIconImage = brandIconImage; + if (brandColor !== undefined) equityData.brandColor = brandColor; + if (hasUsedBenefit !== undefined) equityData.hasUsedBenefit = hasUsedBenefit; + if (createTime) equityData.createTime = createTime; + if (updateTime) equityData.updateTime = updateTime; + + const equity = await UserEquity.create(equityData); + results.push(equity); + } catch (itemError) { + errors.push({ item, error: itemError.message }); + } + } + + res.status(201).json({ + success: true, + data: { + created: results.length, + failed: errors.length, + items: results, + errors: errors.length > 0 ? errors : undefined + } + }); + } catch (error) { + next(error); + } +}); + +router.put('/:id', auth, async (req, res, next) => { + try { + const { + platform, + type, + expireDate, + price, + benefits, + status, + note, + createTime, + updateTime, + platformType, + brandIcon, + brandIconImage, + brandColor, + hasUsedBenefit + } = req.body; + + const updates = {}; + + if (platform !== undefined) updates.platform = platform.trim(); + if (type !== undefined) updates.type = type.trim(); + if (expireDate !== undefined) updates.expireDate = expireDate; + if (price !== undefined) updates.price = parseFloat(price) || 0; + if (benefits !== undefined) updates.benefits = benefits; + if (status !== undefined) updates.status = status; + if (note !== undefined) updates.note = note; + if (platformType !== undefined) updates.platformType = platformType; + if (brandIcon !== undefined) updates.brandIcon = brandIcon; + if (brandIconImage !== undefined) updates.brandIconImage = brandIconImage; + if (brandColor !== undefined) updates.brandColor = brandColor; + if (hasUsedBenefit !== undefined) updates.hasUsedBenefit = hasUsedBenefit; + if (createTime !== undefined) updates.createTime = createTime; + if (updateTime !== undefined) updates.updateTime = updateTime; + + updates.syncedAt = new Date().toISOString(); + + const equity = await UserEquity.findOneAndUpdate( + { _id: req.params.id, owner: req.user._id }, + updates, + { new: true, runValidators: true } + ); + + if (!equity) { + return res.status(404).json({ + success: false, + error: '权益不存在或无权限修改' + }); + } + + res.json({ + success: true, + data: equity + }); + } catch (error) { + next(error); + } +}); + +router.put('/:id/benefits', auth, async (req, res, next) => { + try { + const { benefit, action } = req.body; + + const equity = await UserEquity.findOne({ + _id: req.params.id, + owner: req.user._id + }); + + if (!equity) { + return res.status(404).json({ + success: false, + error: '权益不存在或无权限修改' + }); + } + + const benefits = equity.benefits || []; + + if (action === 'use') { + const benefitIndex = benefits.findIndex(b => b.name === benefit.benefitName); + if (benefitIndex === -1) { + return res.status(404).json({ + success: false, + error: '权益项不存在' + }); + } + benefits[benefitIndex].used = true; + benefits[benefitIndex].usedTime = new Date().toISOString(); + } else if (action === 'delete') { + const benefitIndex = benefits.findIndex(b => b.name === benefit.benefitName); + if (benefitIndex === -1) { + return res.status(404).json({ + success: false, + error: '权益项不存在' + }); + } + benefits.splice(benefitIndex, 1); + } else if (action === 'update') { + const benefitIndex = benefits.findIndex(b => b.name === benefit.oldBenefitName); + if (benefitIndex === -1) { + return res.status(404).json({ + success: false, + error: '权益项不存在' + }); + } + benefits[benefitIndex] = { + ...benefits[benefitIndex].toObject(), + ...benefit.newBenefitData, + updateTime: new Date().toISOString() + }; + } else { + if (!benefit || !benefit.name || !benefit.type) { + return res.status(400).json({ + success: false, + error: '缺少必填字段:权益名称、权益类型' + }); + } + benefits.push({ + name: benefit.name.trim(), + type: benefit.type, + typeLabel: benefit.typeLabel || '', + expireDate: benefit.expireDate || null, + used: false, + usedTime: null + }); + } + + equity.benefits = benefits; + equity.updateTime = new Date().toISOString(); + equity.syncedAt = new Date().toISOString(); + await equity.save(); + + res.json({ + success: true, + data: equity + }); + } catch (error) { + next(error); + } +}); + +router.delete('/:id', auth, async (req, res, next) => { + try { + const equity = await UserEquity.findOneAndDelete({ + _id: req.params.id, + owner: req.user._id + }); + + if (!equity) { + return res.status(404).json({ + success: false, + error: '权益不存在或无权限删除' + }); + } + + res.json({ + success: true, + message: '权益已删除' + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/src/services/wechatSubscribeService.js b/src/services/wechatSubscribeService.js new file mode 100644 index 0000000..7459b3b --- /dev/null +++ b/src/services/wechatSubscribeService.js @@ -0,0 +1,86 @@ +const axios = require('axios'); + +let accessTokenCache = { + token: null, + expiresAt: null +}; + +class WechatSubscribeService { + static async getAccessToken() { + const now = Date.now(); + + if (accessTokenCache.token && accessTokenCache.expiresAt && now < accessTokenCache.expiresAt) { + return accessTokenCache.token; + } + + const appId = process.env.WECHAT_APPID; + const appSecret = process.env.WECHAT_APPSECRET; + + const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`; + + try { + const response = await axios.get(url); + const { access_token, expires_in } = response.data; + + accessTokenCache = { + token: access_token, + expiresAt: now + (expires_in - 300) * 1000 + }; + + return access_token; + } catch (error) { + console.error('获取微信 access_token 失败:', error.response?.data || error.message); + throw new Error('获取微信 access_token 失败'); + } + } + + static async sendSubscribeMessage({ touser, templateId, page, data }) { + const accessToken = await this.getAccessToken(); + const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`; + + const requestData = { + touser, + template_id: templateId, + page: page || 'pages/index/index', + data + }; + + try { + const response = await axios.post(url, requestData); + + if (response.data.errcode === 0) { + return { success: true, messageId: response.data.msgid }; + } else { + console.error('发送订阅消息失败:', response.data); + return { + success: false, + errcode: response.data.errcode, + errmsg: response.data.errmsg + }; + } + } catch (error) { + console.error('发送订阅消息异常:', error.response?.data || error.message); + return { success: false, errmsg: error.message }; + } + } + + static async sendVersionUpdateMessage({ openid, templateId, character_string1, thing3, thing6 }) { + const data = { + character_string1: { value: character_string1 }, + thing3: { value: thing3 } + }; + + if (thing6) { + data.thing6 = { value: thing6 }; + } + + return this.sendSubscribeMessage({ + touser: openid, + templateId, + page: 'pages/index/index', + data + }); + } +} + +module.exports = WechatSubscribeService;