Browse Source

feat: 对接易支付订单退款

支持用户与管理员发起退款,校验剩余次数与7天期限,并同步扣减乐跑次数与写入流水。

Co-authored-by: Cursor <cursoragent@cursor.com>
Pchen. 5 days ago
parent
commit
8d02b9dbb8

+ 19 - 1
apis/Order/Admin/GetOrderDetail.js

@@ -2,6 +2,7 @@ const API = require("../../../lib/API")
 const db = require("../../../plugin/DataBase/db")
 const db = require("../../../plugin/DataBase/db")
 const AccessControl = require("../../../lib/AccessControl")
 const AccessControl = require("../../../lib/AccessControl")
 const { BaseStdResponse } = require("../../../BaseStdResponse")
 const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { evaluateRefundEligibility } = require("../../../lib/OrderRefundService")
 
 
 class GetOrderDetail extends API {
 class GetOrderDetail extends API {
     constructor() {
     constructor() {
@@ -78,9 +79,26 @@ class GetOrderDetail extends API {
             })
             })
         }
         }
 
 
+        const order = rows[0]
+        const userRows = await db.query(
+            'SELECT lepao_count FROM users WHERE uuid = ? LIMIT 1',
+            [order.create_user]
+        )
+        const userLepaoCount = userRows?.[0]?.lepao_count ?? 0
+        const refundEligibility = evaluateRefundEligibility({
+            state: order.state,
+            payTime: order.pay_time,
+            userLepaoCount,
+            goodsLepaoCount: order.lepao_count,
+            skipTimeLimit: true
+        })
+        order.canRefund = refundEligibility.canRefund
+        order.refundDisabledReason = refundEligibility.reason
+        order.user_lepao_count = Number(userLepaoCount || 0)
+
         res.json({
         res.json({
             ...BaseStdResponse.OK,
             ...BaseStdResponse.OK,
-            data: rows[0]
+            data: order
         })
         })
     }
     }
 }
 }

+ 57 - 0
apis/Order/Admin/RefundOrder.js

@@ -0,0 +1,57 @@
+const API = require('../../../lib/API')
+const AccessControl = require('../../../lib/AccessControl')
+const { BaseStdResponse } = require('../../../BaseStdResponse')
+const { executeOrderRefund } = require('../../../lib/OrderRefundService')
+
+class RefundOrder extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Order/RefundOrder')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, orderId } = req.body
+
+        if ([uuid, session, orderId].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        const permission = await AccessControl.getPermission(uuid)
+        if (!permission.includes('admin') && !permission.includes('product')) {
+            return res.json({
+                ...BaseStdResponse.PERMISSION_DENIED
+            })
+        }
+
+        const result = await executeOrderRefund({
+            orderId,
+            operatorUuid: uuid,
+            skipTimeLimit: true,
+            logger: this.logger
+        })
+
+        if (!result.ok) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: result.msg
+            })
+        }
+
+        this.logger.info(`管理员退款成功,订单号:${orderId},操作人:${uuid}`)
+        return res.json({
+            ...BaseStdResponse.OK,
+            msg: result.msg
+        })
+    }
+}
+
+module.exports.RefundOrder = RefundOrder

+ 20 - 1
apis/Order/GetOrderDetail.js

@@ -4,6 +4,7 @@ const Redis = require('../../plugin/DataBase/Redis');
 const config = require('../../config.json');
 const config = require('../../config.json');
 const { BaseStdResponse } = require("../../BaseStdResponse.js");
 const { BaseStdResponse } = require("../../BaseStdResponse.js");
 const AccessControl = require("../../lib/AccessControl.js");
 const AccessControl = require("../../lib/AccessControl.js");
+const { evaluateRefundEligibility } = require('../../lib/OrderRefundService');
 
 
 class GetAccount extends API {
 class GetAccount extends API {
     constructor() {
     constructor() {
@@ -32,7 +33,9 @@ class GetAccount extends API {
                 g.isHot,
                 g.isHot,
                 g.description,
                 g.description,
                 g.category,
                 g.category,
-                g.features
+                g.features,
+                g.lepao_count,
+                g.ic_count
             FROM 
             FROM 
                 orders a
                 orders a
             LEFT JOIN 
             LEFT JOIN 
@@ -73,6 +76,22 @@ class GetAccount extends API {
             });
             });
         }
         }
 
 
+        const userRows = await db.query(
+            'SELECT lepao_count FROM users WHERE uuid = ? LIMIT 1',
+            [uuid]
+        );
+        const userLepaoCount = userRows?.[0]?.lepao_count ?? 0;
+        const refundEligibility = evaluateRefundEligibility({
+            state: order.state,
+            payTime: order.pay_time,
+            userLepaoCount,
+            goodsLepaoCount: order.lepao_count,
+            skipTimeLimit: false
+        });
+        order.canRefund = refundEligibility.canRefund;
+        order.refundDisabledReason = refundEligibility.reason;
+        order.user_lepao_count = Number(userLepaoCount || 0);
+
         // 订单未支付
         // 订单未支付
         if (order.state === 0) {
         if (order.state === 0) {
             let payData = await Redis.get(`payData:${order.orderId}`);
             let payData = await Redis.get(`payData:${order.orderId}`);

+ 70 - 0
apis/Order/RefundOrder.js

@@ -0,0 +1,70 @@
+const API = require('../../lib/API')
+const db = require('../../plugin/DataBase/db')
+const AccessControl = require('../../lib/AccessControl')
+const { BaseStdResponse } = require('../../BaseStdResponse')
+const { executeOrderRefund } = require('../../lib/OrderRefundService')
+
+class RefundOrder extends API {
+    constructor() {
+        super()
+        this.setPath('/Order/RefundOrder')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, orderId } = req.body
+
+        if ([uuid, session, orderId].some(v => v === '' || v === null || v === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+        }
+
+        if (!await AccessControl.checkSession(uuid, session)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+        }
+
+        try {
+            const rows = await db.query(
+                'SELECT orderId FROM orders WHERE orderId = ? AND create_user = ? LIMIT 1',
+                [orderId, uuid]
+            )
+            if (!rows || rows.length !== 1) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '订单不存在'
+                })
+            }
+
+            const result = await executeOrderRefund({
+                orderId,
+                operatorUuid: uuid,
+                skipTimeLimit: false,
+                logger: this.logger
+            })
+
+            if (!result.ok) {
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: result.msg
+                })
+            }
+
+            this.logger.info(`用户申请退款成功,订单号:${orderId}`)
+            return res.json({
+                ...BaseStdResponse.OK,
+                msg: result.msg
+            })
+        } catch (error) {
+            this.logger.error(`用户退款接口异常 ${orderId}: ${error.stack || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '退款失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.RefundOrder = RefundOrder

+ 282 - 0
lib/OrderRefundService.js

@@ -0,0 +1,282 @@
+const axios = require('axios')
+const config = require('../config.json')
+const db = require('../plugin/DataBase/db')
+const { insertLedgerRecord } = require('./Lepao/CountLedger')
+
+const REFUND_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
+const ORDER_STATE_COMPLETED = 2
+const ORDER_STATE_REFUNDED = 5
+
+function evaluateRefundEligibility({
+    state,
+    payTime,
+    userLepaoCount,
+    goodsLepaoCount,
+    skipTimeLimit = false
+}) {
+    if (Number(state) === ORDER_STATE_REFUNDED) {
+        return { canRefund: false, reason: '订单已退款' }
+    }
+    if (Number(state) !== ORDER_STATE_COMPLETED) {
+        return { canRefund: false, reason: '仅已完成订单可申请退款' }
+    }
+    if (!payTime) {
+        return { canRefund: false, reason: '订单支付时间异常' }
+    }
+    if (!skipTimeLimit && Date.now() - Number(payTime) > REFUND_WINDOW_MS) {
+        return { canRefund: false, reason: '已超过7天退款期限' }
+    }
+
+    const purchasedCount = Number(goodsLepaoCount || 0)
+    const remainingCount = Number(userLepaoCount || 0)
+    if (purchasedCount > 0 && remainingCount <= purchasedCount) {
+        return { canRefund: false, reason: '账户剩余次数需大于订单购买次数才可退款' }
+    }
+
+    return { canRefund: true, reason: '' }
+}
+
+function parsePaymentResponseBody(data) {
+    if (data && typeof data === 'object') return data
+    if (typeof data !== 'string') return null
+    const text = data.trim()
+    if (!text) return null
+    try {
+        return JSON.parse(text)
+    } catch (_) {
+        return null
+    }
+}
+
+function formatPaymentRefundError(response, fallbackMessage) {
+    const status = Number(response?.status || 0)
+    const parsed = parsePaymentResponseBody(response?.data)
+
+    if (parsed?.msg) return String(parsed.msg)
+    if (status === 503) return '支付平台暂时不可用,请稍后重试'
+    if (status >= 500) return `支付平台异常(${status}),请稍后重试`
+    if (status >= 400) return `支付平台拒绝退款(${status})`
+    return fallbackMessage || '支付平台退款失败'
+}
+
+async function requestPaymentRefund({ orderId, tradeNo, money, logger }) {
+    const paymentConfig = config.pay || {}
+    if (!paymentConfig.url || !paymentConfig.pid || !paymentConfig.key) {
+        throw new Error('支付配置错误')
+    }
+
+    const params = new URLSearchParams()
+    params.append('pid', String(paymentConfig.pid))
+    params.append('key', paymentConfig.key)
+    if (tradeNo) {
+        params.append('trade_no', tradeNo)
+    } else {
+        params.append('out_trade_no', orderId)
+    }
+    params.append('money', String(money))
+
+    const refundUrl = `${paymentConfig.url}/api.php?act=refund`
+    let response
+    try {
+        response = await axios.post(refundUrl, params.toString(), {
+            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+            timeout: 30000,
+            validateStatus: () => true
+        })
+    } catch (error) {
+        logger?.error?.(`易支付退款请求失败 订单号:${orderId}:${error.stack || error}`)
+        if (error.code === 'ECONNABORTED') {
+            throw new Error('支付平台响应超时,请稍后重试')
+        }
+        throw new Error('无法连接支付平台,请稍后重试')
+    }
+
+    const result = parsePaymentResponseBody(response.data)
+    logger?.info?.(`易支付退款响应 订单号:${orderId},HTTP ${response.status},结果:${JSON.stringify(result ?? response.data)}`)
+
+    if (response.status >= 400) {
+        throw new Error(formatPaymentRefundError(response, '支付平台退款失败'))
+    }
+
+    if (!result || Number(result.code) !== 1) {
+        throw new Error(result?.msg || '支付平台退款失败')
+    }
+
+    return result
+}
+
+async function loadRefundContext(orderId) {
+    const rows = await db.query(
+        `SELECT
+            o.orderId,
+            o.state,
+            o.price,
+            o.pay_id,
+            o.pay_time,
+            o.create_user,
+            g.lepao_count,
+            g.ic_count
+        FROM orders o
+        LEFT JOIN goods g ON o.goods_id = g.id
+        WHERE o.orderId = ?
+        LIMIT 1`,
+        [orderId]
+    )
+    if (!rows || rows.length !== 1) {
+        return null
+    }
+
+    const order = rows[0]
+    const userRows = await db.query(
+        'SELECT lepao_count, ic_count FROM users WHERE uuid = ? LIMIT 1',
+        [order.create_user]
+    )
+    if (!userRows || userRows.length !== 1) {
+        return null
+    }
+
+    return { order, user: userRows[0] }
+}
+
+async function executeOrderRefund({
+    orderId,
+    operatorUuid = null,
+    skipTimeLimit = false,
+    logger
+}) {
+    try {
+        const context = await loadRefundContext(orderId)
+        if (!context) {
+            return { ok: false, msg: '订单或用户不存在' }
+        }
+
+        const { order, user } = context
+        const eligibility = evaluateRefundEligibility({
+            state: order.state,
+            payTime: order.pay_time,
+            userLepaoCount: user.lepao_count,
+            goodsLepaoCount: order.lepao_count,
+            skipTimeLimit
+        })
+        if (!eligibility.canRefund) {
+            return { ok: false, msg: eligibility.reason }
+        }
+
+        const deductLepao = Number(order.lepao_count || 0)
+        const deductIc = Number(order.ic_count || 0)
+        if (deductLepao > 0 && Number(user.lepao_count || 0) < deductLepao) {
+            return { ok: false, msg: '账户乐跑次数不足,无法完成退款' }
+        }
+
+        await requestPaymentRefund({
+            orderId: order.orderId,
+            tradeNo: order.pay_id,
+            money: order.price,
+            logger
+        })
+
+        const conn = await db.connect()
+        try {
+            await conn.beginTransaction()
+
+            const [orderRows] = await conn.execute(
+                `SELECT
+                    o.orderId,
+                    o.state,
+                    o.create_user,
+                    g.lepao_count,
+                    g.ic_count
+                FROM orders o
+                LEFT JOIN goods g ON o.goods_id = g.id
+                WHERE o.orderId = ?
+                FOR UPDATE`,
+                [orderId]
+            )
+            if (!orderRows || orderRows.length !== 1) {
+                await conn.rollback()
+                return { ok: false, msg: '订单不存在' }
+            }
+
+            const lockedOrder = orderRows[0]
+            if (Number(lockedOrder.state) !== ORDER_STATE_COMPLETED) {
+                await conn.rollback()
+                return { ok: false, msg: '订单状态已变更,请刷新后重试' }
+            }
+
+            const [userRows] = await conn.execute(
+                'SELECT lepao_count, ic_count FROM users WHERE uuid = ? FOR UPDATE',
+                [lockedOrder.create_user]
+            )
+            if (!userRows || userRows.length !== 1) {
+                await conn.rollback()
+                return { ok: false, msg: '用户不存在' }
+            }
+
+            const lockedUser = userRows[0]
+            const lockedDeductLepao = Number(lockedOrder.lepao_count || 0)
+            const lockedDeductIc = Number(lockedOrder.ic_count || 0)
+            const beforeLepao = Number(lockedUser.lepao_count || 0)
+            const beforeIc = Number(lockedUser.ic_count || 0)
+
+            if (lockedDeductLepao > 0 && beforeLepao < lockedDeductLepao) {
+                await conn.rollback()
+                logger?.error?.(`退款支付已成功但扣次失败,需人工处理,订单号:${orderId}`)
+                return { ok: false, msg: '支付已退款但扣减次数失败,请联系客服处理' }
+            }
+
+            const afterLepao = beforeLepao - lockedDeductLepao
+            const afterIc = Math.max(0, beforeIc - lockedDeductIc)
+
+            const [updateUserRes] = await conn.execute(
+                'UPDATE users SET lepao_count = ?, ic_count = ? WHERE uuid = ?',
+                [afterLepao, afterIc, lockedOrder.create_user]
+            )
+            if (!updateUserRes || updateUserRes.affectedRows !== 1) {
+                await conn.rollback()
+                logger?.error?.(`退款支付已成功但更新用户失败,需人工处理,订单号:${orderId}`)
+                return { ok: false, msg: '支付已退款但更新账户失败,请联系客服处理' }
+            }
+
+            const [updateOrderRes] = await conn.execute(
+                'UPDATE orders SET state = ? WHERE orderId = ? AND state = ?',
+                [ORDER_STATE_REFUNDED, orderId, ORDER_STATE_COMPLETED]
+            )
+            if (!updateOrderRes || updateOrderRes.affectedRows !== 1) {
+                await conn.rollback()
+                logger?.error?.(`退款支付已成功但更新订单失败,需人工处理,订单号:${orderId}`)
+                return { ok: false, msg: '支付已退款但更新订单失败,请联系客服处理' }
+            }
+
+            if (lockedDeductLepao !== 0) {
+                await insertLedgerRecord({
+                    executor: conn,
+                    userUuid: lockedOrder.create_user,
+                    delta: -lockedDeductLepao,
+                    balanceBefore: beforeLepao,
+                    balanceAfter: afterLepao,
+                    bizType: 'purchase_refund',
+                    bizId: orderId,
+                    operatorUuid,
+                    remark: `订单退款:${orderId}`
+                })
+            }
+
+            await conn.commit()
+            return { ok: true, msg: '退款成功' }
+        } catch (dbError) {
+            try { await conn.rollback() } catch (_) { }
+            logger?.error?.(`退款入账失败 ${orderId}: ${dbError.stack || dbError}`)
+            return { ok: false, msg: '支付已退款但入账失败,请联系客服处理' }
+        }
+    } catch (error) {
+        logger?.error?.(`订单退款失败 ${orderId}: ${error.stack || error}`)
+        return { ok: false, msg: error.message || '退款失败,请稍后再试' }
+    }
+}
+
+module.exports = {
+    REFUND_WINDOW_MS,
+    ORDER_STATE_REFUNDED,
+    evaluateRefundEligibility,
+    executeOrderRefund
+}