diff --git a/client/src/pages.json b/client/src/pages.json index 39ed0e1..f17c28d 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -124,6 +124,12 @@ "navigationBarTitleText": "协议", "navigationStyle": "custom" } + }, + { + "path": "pages/vpay/recharge", + "style": { + "navigationBarTitleText": "充值星尘" + } } ], "globalStyle": { diff --git a/client/src/pages/profile/profile.vue b/client/src/pages/profile/profile.vue index 5f85216..b3910a3 100644 --- a/client/src/pages/profile/profile.vue +++ b/client/src/pages/profile/profile.vue @@ -87,6 +87,16 @@ + + + ✨ + + 充值星尘 + AI问答 · 会员订阅 + + → + + @@ -261,6 +271,7 @@ async function handleLogout() { function goLogin() { uni.navigateTo({ url: '/pages/login/login' }) } +function goRecharge() { uni.navigateTo({ url: '/pages/vpay/recharge' }) } function goChat() { uni.switchTab({ url: '/pages/chat/chat' }) } function goFavorites() { uni.navigateTo({ url: '/pages/favorites/favorites' }) } function goProgress() { uni.navigateTo({ url: '/pages/progress/progress' }) } @@ -624,3 +635,17 @@ onShareTimeline(() => ({ padding: 8px 0 20px; } + + diff --git a/client/src/pages/vpay/recharge.vue b/client/src/pages/vpay/recharge.vue new file mode 100644 index 0000000..e339a7a --- /dev/null +++ b/client/src/pages/vpay/recharge.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/client/src/utils/api.js b/client/src/utils/api.js index b0f5222..21ec5f9 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -12,7 +12,7 @@ const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.DEV ? DEV_URL /** * 通用请求封装 */ -async function request(method, path, data = null, token = null) { +export async function request(method, path, data = null, token = null) { const url = `${API_BASE}${path}` const headers = { 'Content-Type': 'application/json' diff --git a/server/src/index.js b/server/src/index.js index 5a2631f..d63e960 100755 --- a/server/src/index.js +++ b/server/src/index.js @@ -11,6 +11,7 @@ const connectDB = require('./config/database') const initRoutes = require('./routes') const seoRoutes = require('./routes/seo') const registerWxMsgRoutes = require('./routes/wxmsg') +const registerVpayRoutes = require('./routes/vpay') const logger = require('./utils/logger') // 创建 Express 应用 @@ -75,6 +76,7 @@ app.get('/health', (req, res) => { // API 路由 registerWxMsgRoutes(app) +registerVpayRoutes(app) initRoutes(app) // 404 处理 diff --git a/server/src/models/User.js b/server/src/models/User.js index d00eb19..d44511b 100755 --- a/server/src/models/User.js +++ b/server/src/models/User.js @@ -1,311 +1,314 @@ -const mongoose = require('mongoose') -const { Schema } = mongoose -const bcrypt = require('bcryptjs') - -/** - * 用户探索数据结构 - */ -const ExploreDataSchema = new Schema({ - dim1: { - completed: { type: Boolean, default: false }, - bestScore: { type: Number, default: 0 }, - collectedEggs: { type: Number, default: 0 }, - totalLength: { type: Number, default: 0 } - }, - dim2: { - completed: { type: Boolean, default: false }, - bestArea: { type: Number, default: 0 }, - createdShapes: { type: Number, default: 0 } - }, - dim3: { - completed: { type: Boolean, default: false }, - exploredFaces: { type: Number, default: 0 }, - rotationTime: { type: Number, default: 0 }, - bestScore: { type: Number, default: 0 } - }, - dim4: { - completed: { type: Boolean, default: false }, - bestScore: { type: Number, default: 0 }, - exploredEvents: { type: Number, default: 0 } - }, - dim5: { - completed: { type: Boolean, default: false }, - bestScore: { type: Number, default: 0 }, - exploredThoughts: { type: Number, default: 0 } - } -}, { _id: false }) - -/** - * 用户模型 - */ -const UserSchema = new Schema({ - // 微信用户标识 - openid: { - type: String, - required: false, - unique: true, - sparse: true, - index: true - }, - unionid: { - type: String, - index: true - }, - - // H5环境用户名密码登录 - username: { - type: String, - unique: true, - sparse: true, - index: true - }, - email: { - type: String, - unique: true, - sparse: true, - index: true - }, - password: { - type: String, - select: false - }, - - // 基本信息 - nickName: { - type: String, - default: '星辰旅行者' - }, - avatarUrl: { - type: String, - default: '' - }, - - // 探索数据 - exploreData: { - type: ExploreDataSchema, - default: () => ({}) - }, - - // 分数(冗余字段,便于排行榜查询) - totalScore: { type: Number, default: 0 }, - dim1Score: { type: Number, default: 0 }, - dim2Score: { type: Number, default: 0 }, - dim3Score: { type: Number, default: 0 }, - dim4Score: { type: Number, default: 0 }, - dim5Score: { type: Number, default: 0 }, - - // 已解锁维度(新用户默认解锁第1维度) - unlockedDims: { - type: [{ type: Number, enum: [1, 2, 3, 4, 5] }], - default: [1] - }, - - // 商品相关 - ownedSkins: [{ - type: String - }], - purchasedItems: [{ - type: String - }], - - // 会员状态 - isAdFree: { - type: Boolean, - default: false - }, - isSubscriber: { - type: Boolean, - default: false - }, - subscribeExpiry: { - type: Date - }, - - // 收藏与成就 - collectedKnowledge: [{ - type: mongoose.Schema.Types.ObjectId, - ref: 'Knowledge' - }], - achievements: [{ - type: String - }], - - // 设置 - settings: { - voiceEnabled: { type: Boolean, default: true }, - difficulty: { - type: String, - enum: ['easy', 'normal', 'hard'], - default: 'normal' - } - }, - - // 探索等级系统 - level: { - type: Number, - default: 1 - }, - levelPoints: { - type: Number, - default: 0 - }, - - // 时间戳 - totalPlayTime: { - type: Number, - default: 0 - }, - createdAt: { - type: Date, - default: Date.now - }, - lastLoginAt: { - type: Date, - default: Date.now - }, - updatedAt: { - type: Date, - default: Date.now - } -}, { - timestamps: true, - toJSON: { virtuals: true }, - toObject: { virtuals: true } -}) - -// 索引 -UserSchema.index({ totalScore: -1 }) -UserSchema.index({ dim1Score: -1 }) -UserSchema.index({ dim2Score: -1 }) -UserSchema.index({ dim3Score: -1 }) -UserSchema.index({ dim4Score: -1 }) -UserSchema.index({ dim5Score: -1 }) -UserSchema.index({ createdAt: -1 }) -UserSchema.index({ lastLoginAt: -1 }) // 用于活跃用户统计 - -// 虚拟字段:是否是新用户(24小时内注册) -UserSchema.virtual('isNew').get(function() { - return Date.now() - this.createdAt < 24 * 60 * 60 * 1000 -}) - -// 中间件:保存前加密密码(仅针对username/password登录) -UserSchema.pre('save', async function(next) { - if (!this.isModified('password')) { - return next() - } - - try { - const salt = await bcrypt.genSalt(10) - this.password = await bcrypt.hash(this.password, salt) - next() - } catch (error) { - next(error) - } -}) - -// 实例方法:验证密码 -UserSchema.methods.validatePassword = async function(password) { - return bcrypt.compare(password, this.password) -} - -// 实例方法:计算总分 -UserSchema.methods.calculateTotalScore = function() { - this.totalScore = - (this.exploreData.dim1?.bestScore || 0) + - (this.exploreData.dim2?.bestArea || 0) + - (this.exploreData.dim3?.bestScore || 0) + - (this.exploreData.dim4?.bestScore || 0) + - (this.exploreData.dim5?.bestScore || 0) - - // 同步维度分数 - this.dim1Score = this.exploreData.dim1?.bestScore || 0 - this.dim2Score = this.exploreData.dim2?.bestArea || 0 - this.dim3Score = this.exploreData.dim3?.bestScore || 0 - this.dim4Score = this.exploreData.dim4?.bestScore || 0 - this.dim5Score = this.exploreData.dim5?.bestScore || 0 - - return this.totalScore -} - -// 实例方法:计算等级 -UserSchema.methods.calculateLevel = function() { - // 等级计算公式:基于总分和探索时长 - // 基础分:每100分 = 1级 - const scorePoints = Math.floor(this.totalScore / 100) - // 时长分:每1小时 = 1级 - const timePoints = Math.floor(this.totalPlayTime / 60) - // 成就分:每个成就 = 2级 - const achievementPoints = (this.achievements?.length || 0) * 2 - // 收藏分:每5个收藏 = 1级 - const collectionPoints = Math.floor((this.collectedKnowledge?.length || 0) / 5) - - this.levelPoints = scorePoints + timePoints + achievementPoints + collectionPoints - - // 等级阈值表 - const levelThresholds = [ - 0, 5, 15, 30, 50, 75, 105, 140, 180, 225, - 275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050 - ] - - // 计算等级 - let newLevel = 1 - for (let i = 0; i < levelThresholds.length; i++) { - if (this.levelPoints >= levelThresholds[i]) { - newLevel = i + 1 - } else { - break - } - } - - this.level = newLevel - return this.level -} - -// 实例方法:获取等级信息 -UserSchema.methods.getLevelInfo = function() { - const levelNames = [ - '星辰旅者', '维度学徒', '空间探索者', '几何学者', '维度行者', - '时空旅人', '多维大师', '宇宙探索者', '维度掌控者', '空间主宰', - '维度领主', '宇宙行者', '时空主宰', '维度之神', '宇宙之主', - '维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神' - ] - - const levelThresholds = [ - 0, 5, 15, 30, 50, 75, 105, 140, 180, 225, - 275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050 - ] - - const currentLevel = this.level || 1 - const currentPoints = this.levelPoints || 0 - const nextLevelPoints = levelThresholds[currentLevel] || levelThresholds[levelThresholds.length - 1] - const progress = Math.min(100, Math.floor((currentPoints / nextLevelPoints) * 100)) - - return { - level: currentLevel, - name: levelNames[Math.min(currentLevel - 1, levelNames.length - 1)], - points: currentPoints, - nextLevelPoints: nextLevelPoints, - progress: progress, - totalProgress: Math.min(100, Math.floor((currentPoints / 1050) * 100)) - } -} - -// 静态方法:获取排行榜 -UserSchema.statics.getLeaderboard = function(dim = null, limit = 50) { - const sortField = dim ? `dim${dim}Score` : 'totalScore' - return this.find() - .select('openid nickName avatarUrl totalScore dim1Score dim2Score dim3Score dim4Score dim5Score unlockedDims') - .sort({ [sortField]: -1 }) - .limit(limit) - .lean() -} - -// 中间件:保存前更新 updatedAt、计算总分和等级 -UserSchema.pre('save', function(next) { - this.updatedAt = Date.now() - this.calculateTotalScore() - this.calculateLevel() - next() -}) - -module.exports = mongoose.model('User', UserSchema) +const mongoose = require('mongoose') +const { Schema } = mongoose +const bcrypt = require('bcryptjs') + +/** + * 用户探索数据结构 + */ +const ExploreDataSchema = new Schema({ + dim1: { + completed: { type: Boolean, default: false }, + bestScore: { type: Number, default: 0 }, + collectedEggs: { type: Number, default: 0 }, + totalLength: { type: Number, default: 0 } + }, + dim2: { + completed: { type: Boolean, default: false }, + bestArea: { type: Number, default: 0 }, + createdShapes: { type: Number, default: 0 } + }, + dim3: { + completed: { type: Boolean, default: false }, + exploredFaces: { type: Number, default: 0 }, + rotationTime: { type: Number, default: 0 }, + bestScore: { type: Number, default: 0 } + }, + dim4: { + completed: { type: Boolean, default: false }, + bestScore: { type: Number, default: 0 }, + exploredEvents: { type: Number, default: 0 } + }, + dim5: { + completed: { type: Boolean, default: false }, + bestScore: { type: Number, default: 0 }, + exploredThoughts: { type: Number, default: 0 } + } +}, { _id: false }) + +/** + * 用户模型 + */ +const UserSchema = new Schema({ + // 微信用户标识 + openid: { + type: String, + required: false, + unique: true, + sparse: true, + index: true + }, + unionid: { + type: String, + index: true + }, + + // H5环境用户名密码登录 + username: { + type: String, + unique: true, + sparse: true, + index: true + }, + email: { + type: String, + unique: true, + sparse: true, + index: true + }, + password: { + type: String, + select: false + }, + + // 基本信息 + nickName: { + type: String, + default: '星辰旅行者' + }, + avatarUrl: { + type: String, + default: '' + }, + + // 探索数据 + exploreData: { + type: ExploreDataSchema, + default: () => ({}) + }, + + // 分数(冗余字段,便于排行榜查询) + totalScore: { type: Number, default: 0 }, + dim1Score: { type: Number, default: 0 }, + dim2Score: { type: Number, default: 0 }, + dim3Score: { type: Number, default: 0 }, + dim4Score: { type: Number, default: 0 }, + dim5Score: { type: Number, default: 0 }, + + // 已解锁维度(新用户默认解锁第1维度) + unlockedDims: { + type: [{ type: Number, enum: [1, 2, 3, 4, 5] }], + default: [1] + }, + + // 商品相关 + ownedSkins: [{ + type: String + }], + purchasedItems: [{ + type: String + }], + + // 会员状态 + isAdFree: { + type: Boolean, + default: false + }, + isSubscriber: { + type: Boolean, + default: false + }, + subscribeExpiry: { + type: Date + }, + + // 收藏与成就 + collectedKnowledge: [{ + type: mongoose.Schema.Types.ObjectId, + ref: 'Knowledge' + }], + achievements: [{ + type: String + }], + + // 设置 + settings: { + voiceEnabled: { type: Boolean, default: true }, + difficulty: { + type: String, + enum: ['easy', 'normal', 'hard'], + default: 'normal' + } + }, + + // 探索等级系统 + level: { + type: Number, + default: 1 + }, + levelPoints: { + type: Number, + default: 0 + }, + + // 时间戳 + totalPlayTime: { + type: Number, + default: 0 + }, + createdAt: { + type: Date, + default: Date.now + }, + // vpay 用户态签名必需(code2Session 返回,有过期时间,过期后重登录刷新) + sessionKey: { type: String, default: '' }, + vpayCoins: { type: Number, default: 0 }, + lastLoginAt: { + type: Date, + default: Date.now + }, + updatedAt: { + type: Date, + default: Date.now + } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}) + +// 索引 +UserSchema.index({ totalScore: -1 }) +UserSchema.index({ dim1Score: -1 }) +UserSchema.index({ dim2Score: -1 }) +UserSchema.index({ dim3Score: -1 }) +UserSchema.index({ dim4Score: -1 }) +UserSchema.index({ dim5Score: -1 }) +UserSchema.index({ createdAt: -1 }) +UserSchema.index({ lastLoginAt: -1 }) // 用于活跃用户统计 + +// 虚拟字段:是否是新用户(24小时内注册) +UserSchema.virtual('isNew').get(function() { + return Date.now() - this.createdAt < 24 * 60 * 60 * 1000 +}) + +// 中间件:保存前加密密码(仅针对username/password登录) +UserSchema.pre('save', async function(next) { + if (!this.isModified('password')) { + return next() + } + + try { + const salt = await bcrypt.genSalt(10) + this.password = await bcrypt.hash(this.password, salt) + next() + } catch (error) { + next(error) + } +}) + +// 实例方法:验证密码 +UserSchema.methods.validatePassword = async function(password) { + return bcrypt.compare(password, this.password) +} + +// 实例方法:计算总分 +UserSchema.methods.calculateTotalScore = function() { + this.totalScore = + (this.exploreData.dim1?.bestScore || 0) + + (this.exploreData.dim2?.bestArea || 0) + + (this.exploreData.dim3?.bestScore || 0) + + (this.exploreData.dim4?.bestScore || 0) + + (this.exploreData.dim5?.bestScore || 0) + + // 同步维度分数 + this.dim1Score = this.exploreData.dim1?.bestScore || 0 + this.dim2Score = this.exploreData.dim2?.bestArea || 0 + this.dim3Score = this.exploreData.dim3?.bestScore || 0 + this.dim4Score = this.exploreData.dim4?.bestScore || 0 + this.dim5Score = this.exploreData.dim5?.bestScore || 0 + + return this.totalScore +} + +// 实例方法:计算等级 +UserSchema.methods.calculateLevel = function() { + // 等级计算公式:基于总分和探索时长 + // 基础分:每100分 = 1级 + const scorePoints = Math.floor(this.totalScore / 100) + // 时长分:每1小时 = 1级 + const timePoints = Math.floor(this.totalPlayTime / 60) + // 成就分:每个成就 = 2级 + const achievementPoints = (this.achievements?.length || 0) * 2 + // 收藏分:每5个收藏 = 1级 + const collectionPoints = Math.floor((this.collectedKnowledge?.length || 0) / 5) + + this.levelPoints = scorePoints + timePoints + achievementPoints + collectionPoints + + // 等级阈值表 + const levelThresholds = [ + 0, 5, 15, 30, 50, 75, 105, 140, 180, 225, + 275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050 + ] + + // 计算等级 + let newLevel = 1 + for (let i = 0; i < levelThresholds.length; i++) { + if (this.levelPoints >= levelThresholds[i]) { + newLevel = i + 1 + } else { + break + } + } + + this.level = newLevel + return this.level +} + +// 实例方法:获取等级信息 +UserSchema.methods.getLevelInfo = function() { + const levelNames = [ + '星辰旅者', '维度学徒', '空间探索者', '几何学者', '维度行者', + '时空旅人', '多维大师', '宇宙探索者', '维度掌控者', '空间主宰', + '维度领主', '宇宙行者', '时空主宰', '维度之神', '宇宙之主', + '维度创世者', '空间造物主', '宇宙掌控者', '维度至尊', '宇宙之神' + ] + + const levelThresholds = [ + 0, 5, 15, 30, 50, 75, 105, 140, 180, 225, + 275, 330, 390, 455, 525, 600, 680, 765, 855, 950, 1050 + ] + + const currentLevel = this.level || 1 + const currentPoints = this.levelPoints || 0 + const nextLevelPoints = levelThresholds[currentLevel] || levelThresholds[levelThresholds.length - 1] + const progress = Math.min(100, Math.floor((currentPoints / nextLevelPoints) * 100)) + + return { + level: currentLevel, + name: levelNames[Math.min(currentLevel - 1, levelNames.length - 1)], + points: currentPoints, + nextLevelPoints: nextLevelPoints, + progress: progress, + totalProgress: Math.min(100, Math.floor((currentPoints / 1050) * 100)) + } +} + +// 静态方法:获取排行榜 +UserSchema.statics.getLeaderboard = function(dim = null, limit = 50) { + const sortField = dim ? `dim${dim}Score` : 'totalScore' + return this.find() + .select('openid nickName avatarUrl totalScore dim1Score dim2Score dim3Score dim4Score dim5Score unlockedDims') + .sort({ [sortField]: -1 }) + .limit(limit) + .lean() +} + +// 中间件:保存前更新 updatedAt、计算总分和等级 +UserSchema.pre('save', function(next) { + this.updatedAt = Date.now() + this.calculateTotalScore() + this.calculateLevel() + next() +}) + +module.exports = mongoose.model('User', UserSchema) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index f3f26c1..d878ad8 100755 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -87,6 +87,7 @@ router.post('/login', async (req, res) => { if (code) { const sessionData = await weChatService.code2Session(code) openid = sessionData.openid + req.body._sessionKey = sessionData.sessionKey || '' } // 方式2: 开发环境直接使用 openid (已废弃,仅用于本地调试) @@ -118,6 +119,7 @@ router.post('/login', async (req, res) => { if (userInfo?.nickName) user.nickName = userInfo.nickName if (userInfo?.avatarUrl) user.avatarUrl = userInfo.avatarUrl } + if (req.body._sessionKey) user.sessionKey = req.body._sessionKey await user.save() diff --git a/server/src/routes/vpay.js b/server/src/routes/vpay.js new file mode 100644 index 0000000..8ddf4c9 --- /dev/null +++ b/server/src/routes/vpay.js @@ -0,0 +1,119 @@ +const express = require('express') +const crypto = require('crypto') +const router = express.Router() +const { User, Order, AIChatQuota } = require('../models') +const ApiResponse = require('../utils/response') +const { userAuthMiddleware } = require('../middleware/auth') +const logger = require('../utils/logger') + +// ===== 环境与密钥 ===== +const ENV = parseInt(process.env.VPAY_ENV || '1') // 0=现网 1=沙箱 +const OFFER_ID = process.env.VPAY_OFFER_ID || '' +const APPKEY = ENV === 0 ? (process.env.VPAY_APPKEY_PROD || '') : (process.env.VPAY_APPKEY_SANDBOX || '') + +/** 代币名称(前端展示用) */ +const COIN_NAME = '星尘' + +/** 充值档位(前端传 productId → 此表映射;1:100 兑换比例下分即星尘数) */ +const RECHARGE_PACKAGES = [ + { id: 'pack_600', coins: 600, priceFen: 600, label: '600 星尘' }, + { id: 'pack_1800', coins: 1800, priceFen: 1800, label: '1800 星尘' }, + { id: 'pack_5000', coins: 5000, priceFen: 5000, label: '5000 星尘' }, + { id: 'pack_12000', coins: 12000, priceFen: 12000, label: '12000 星尘' }, +] + +// ===== 签名工具 ===== +function hmacSha256Hex(key, data) { + return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex') +} + +/** + * 生成虚拟支付拉起参数(客户端 wx.requestVirtualPayment 直接用) + * 服务端计算两个签名: + * paySig = hmac_sha256(appKey, "requestVirtualPayment&" + signData) + * signature = hmac_sha256(sessionKey, signData) + */ +function buildPayParams(user, pack, outTradeNo) { + const signData = JSON.stringify({ + mode: 'coin', + offerId: OFFER_ID, + buyQuantity: pack.coins, + outTradeNo, + custom: '', + attach: JSON.stringify({ uid: String(user._id), pack: pack.id }), + ts: Math.floor(Date.now() / 1000), + }) + return { + mode: 'coin', + env: ENV, + offerId: OFFER_ID, + buyQuantity: pack.coins, + outTradeNo, + currencyType: 'CNY', + platform: 'android', + signData, + paySig: hmacSha256Hex(APPKEY, `requestVirtualPayment&${signData}`), + signature: hmacSha256Hex(user.sessionKey || '', signData), + } +} + +// ===== 接口 ===== + +/** + * 查询充值档位 + 当前星尘余额 + * GET /api/vpay/info + */ +router.get('/info', userAuthMiddleware, async (req, res) => { + try { + const user = req.user + return ApiResponse.success(res, { + coinName: COIN_NAME, + env: ENV === 0 ? 'production' : 'sandbox', + packages: RECHARGE_PACKAGES.map(p => ({ id: p.id, coins: p.coins, priceYuan: (p.priceFen / 100).toFixed(2), label: p.label })), + balance: user.vpayCoins || 0, + }) + } catch (error) { + logger.error('vpay info 失败:', error) + return ApiResponse.serverError(res, '获取充值信息失败') + } +}) + +/** + * 创建充值订单(返回客户端拉起支付的完整参数) + * POST /api/vpay/recharge { packageId } + */ +router.post('/recharge', userAuthMiddleware, async (req, res) => { + try { + const user = req.user + const { packageId } = req.body + const pack = RECHARGE_PACKAGES.find(p => p.id === packageId) + if (!pack) return ApiResponse.error(res, '无效的充值档位', 400) + if (!user.sessionKey) { + return ApiResponse.error(res, '登录态已过期,请重新登录后再充值', 401, { needRelogin: true }) + } + + const outTradeNo = `VP${Date.now()}${crypto.randomBytes(3).toString('hex').toUpperCase()}` + + // 记订单 + const order = new Order({ + orderNo: outTradeNo, + openid: user.openid, + userId: user._id, + productId: pack.id, + productName: pack.label, + amount: pack.priceFen / 100, + status: 'pending', + metadata: { type: 'vpay_recharge', coins: pack.coins, env: ENV }, + }) + await order.save() + + const payParams = buildPayParams(user, pack, outTradeNo) + logger.info(`[vpay] 充值订单 ${outTradeNo} 创建 (${pack.label}, env=${ENV})`) + return ApiResponse.success(res, { orderNo: outTradeNo, payParams }) + } catch (error) { + logger.error('vpay recharge 失败:', error) + return ApiResponse.serverError(res, '创建充值订单失败') + } +}) + +module.exports = router diff --git a/server/src/routes/wxmsg.js b/server/src/routes/wxmsg.js index 962837f..c783e04 100644 --- a/server/src/routes/wxmsg.js +++ b/server/src/routes/wxmsg.js @@ -47,10 +47,39 @@ module.exports = function registerWxMsgRoutes(app) { } const event = fields.Event || fields.event || '' console.log('[wx-msg] 收到推送 Event=' + event, JSON.stringify(fields).slice(0, 500)) - // TODO(vpay接入):按事件分发改写用户星尘余额/发货状态 - // xpay_coin_pay_notify 代币充值到账 - // xpay_goods_deliver_notify 道具发货 - // xpay_refund_notify 退款 + + // 代币充值到账:改写用户星尘余额 + 订单状态 + if (event === 'xpay_coin_pay_notify') { + try { + const { User, Order } = require('../models') + const outTradeNo = fields.OutTradeNo || '' + const coins = parseInt(fields.BuyQuantity || fields.buyQuantity || '0') + if (outTradeNo && coins > 0) { + const order = await Order.findOne({ orderNo: outTradeNo }) + if (order && order.status === 'pending') { + await Order.updateOne({ _id: order._id }, { status: 'paid', paidAt: new Date() }) + await User.updateOne({ _id: order.userId }, { $inc: { vpayCoins: coins } }) + console.log(`[wx-msg] 星尘充值到账: ${outTradeNo} +${coins}`) + } + } + } catch (e) { console.error('[wx-msg] 充值到账失败:', e.message) } + } + + // 退款:扣回星尘 + if (event === 'xpay_refund_notify') { + try { + const { User, Order } = require('../models') + const outTradeNo = fields.OutTradeNo || '' + const order = await Order.findOne({ orderNo: outTradeNo, status: 'paid' }) + if (order) { + await Order.updateOne({ _id: order._id }, { status: 'refunded' }) + const coins = (order.metadata || {}).coins || 0 + if (coins > 0) await User.updateOne({ _id: order.userId }, { $inc: { vpayCoins: -coins } }) + console.log(`[wx-msg] 星尘退款: ${outTradeNo} -${coins}`) + } + } catch (e) { console.error('[wx-msg] 退款处理失败:', e.message) } + } + return res.status(200).json({ ErrCode: 0, ErrMsg: '' }) } catch (e) { console.error('[wx-msg] 处理失败:', e.message)