feat: sync backend with frontend - add all UserEquity fields (brandIcon, brandIconImage, brandColor, platformType, hasUsedBenefit, benefit sub-fields, etc.) and new routes

This commit is contained in:
Developer
2026-05-13 20:08:48 +08:00
parent 519f4c2def
commit 9c52975b5a
17 changed files with 1613 additions and 1 deletions
+72
View File
@@ -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);
+76
View File
@@ -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);
+28
View File
@@ -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
+160
View File
@@ -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);
+47
View File
@@ -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);
+41
View File
@@ -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);
+7 -1
View File
@@ -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
}
}
});
+52
View File
@@ -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;
+79
View File
@@ -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;
+105
View File
@@ -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;
+103
View File
@@ -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;
+206
View File
@@ -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;
+116
View File
@@ -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;
+8
View File
@@ -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,
+417
View File
@@ -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;
+86
View File
@@ -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;