Browse Source

✨ feat: 增加优惠码功能

Pchen. 3 weeks ago
parent
commit
f903dc358e

+ 64 - 0
apis/Coupon/Admin/Detail.js

@@ -0,0 +1,64 @@
+const API = require('../../../lib/API.js')
+const db = require('../../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../../BaseStdResponse.js')
+const AccessControl = require('../../../lib/AccessControl.js')
+class CouponDetail extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Coupon/Detail')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, id } = req.query
+
+        if ([uuid, session, id].some((v) => v === '' || v == null)) {
+            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 rows = await db.query('SELECT * FROM coupons WHERE id = ? LIMIT 1', [id])
+        if (!rows || rows.length !== 1) {
+            return res.json({ ...BaseStdResponse.MISSING_FILE, msg: '优惠码不存在' })
+        }
+
+        const coupon = rows[0]
+        const users = await db.query(
+            `SELECT cu.user_uuid, u.username FROM coupon_users cu
+             LEFT JOIN users u ON u.uuid = cu.user_uuid
+             WHERE cu.coupon_id = ?`,
+            [id]
+        )
+        const goods = await db.query(
+            `SELECT cg.goods_id, g.name FROM coupon_goods cg
+             LEFT JOIN goods g ON g.id = cg.goods_id
+             WHERE cg.coupon_id = ?`,
+            [id]
+        )
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                ...coupon,
+                allowedUsers: (users || []).map((r) => ({
+                    uuid: r.user_uuid,
+                    username: r.username
+                })),
+                allowedGoods: (goods || []).map((r) => ({
+                    id: r.goods_id,
+                    name: r.name
+                }))
+            }
+        })
+    }
+}
+
+module.exports.CouponDetail = CouponDetail

+ 80 - 0
apis/Coupon/Admin/List.js

@@ -0,0 +1,80 @@
+const API = require('../../../lib/API.js')
+const db = require('../../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../../BaseStdResponse.js')
+const AccessControl = require('../../../lib/AccessControl.js')
+
+class ListCoupons extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Coupon/List')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, pagesize, current, keyword, state } = req.query
+
+        if ([uuid, session, pagesize, current].some((v) => v === '' || v == null)) {
+            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 pageSize = Math.max(1, Number(pagesize))
+        const page = Math.max(1, Number(current))
+        const offset = (page - 1) * pageSize
+
+        let sql = `SELECT * FROM coupons WHERE 1=1`
+        let countSql = `SELECT COUNT(*) AS total FROM coupons WHERE 1=1`
+        const params = []
+        const countParams = []
+
+        if (keyword) {
+            sql += ` AND (code LIKE ? OR name LIKE ?)`
+            countSql += ` AND (code LIKE ? OR name LIKE ?)`
+            const kw = `%${keyword}%`
+            params.push(kw, kw)
+            countParams.push(kw, kw)
+        }
+
+        const stateNum = Number(state)
+        if (
+            state !== undefined &&
+            state !== '' &&
+            state !== '-1' &&
+            !Number.isNaN(stateNum) &&
+            stateNum !== -1
+        ) {
+            sql += ` AND state = ?`
+            countSql += ` AND state = ?`
+            params.push(stateNum)
+            countParams.push(stateNum)
+        }
+
+        sql += ` ORDER BY id DESC LIMIT ? OFFSET ?`
+        params.push(String(pageSize), String(offset))
+
+        const rows = await db.query(sql, params)
+        const countResult = await db.query(countSql, countParams)
+        const total = Number(countResult?.[0]?.total || 0)
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: rows || [],
+            pagination: {
+                current: page,
+                pagesize: pageSize,
+                total,
+                totalPages: Math.ceil(total / pageSize)
+            }
+        })
+    }
+}
+
+module.exports.ListCoupons = ListCoupons

+ 202 - 0
apis/Coupon/Admin/Save.js

@@ -0,0 +1,202 @@
+const API = require('../../../lib/API.js')
+const db = require('../../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../../BaseStdResponse.js')
+const AccessControl = require('../../../lib/AccessControl.js')
+function parseUsernames(text) {
+    if (!text) return []
+    return [...new Set(
+        String(text)
+            .split(/[,,\n\r\s]+/)
+            .map((s) => s.trim())
+            .filter(Boolean)
+    )]
+}
+
+class SaveCoupon extends API {
+    constructor() {
+        super()
+        this.setPath('/Admin/Coupon/Save')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const {
+            uuid,
+            session,
+            id,
+            code,
+            name,
+            discount_type,
+            discount_value,
+            user_scope,
+            goods_scope,
+            total_limit,
+            per_user_limit,
+            min_amount,
+            start_time,
+            end_time,
+            state,
+            allowed_usernames,
+            allowed_goods_ids
+        } = req.body
+
+        if ([uuid, session, code, discount_type, discount_value, user_scope, goods_scope, state].some(
+            (v) => v === '' || v == null
+        )) {
+            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 normalizedCode = String(code).trim().toUpperCase()
+        if (!/^[A-Z0-9_-]{3,32}$/.test(normalizedCode)) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '优惠码仅支持 3-32 位字母、数字、下划线或横线'
+            })
+        }
+
+        if (!['percent', 'fixed'].includes(discount_type)) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '折扣类型无效' })
+        }
+
+        const dValue = Number(discount_value)
+        if (discount_type === 'percent' && (dValue <= 0 || dValue > 100)) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '折扣比例需在 1~100 之间' })
+        }
+        if (discount_type === 'fixed' && dValue <= 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '减免金额需大于 0' })
+        }
+
+        const uScope = Number(user_scope)
+        const gScope = Number(goods_scope)
+        const usernames = parseUsernames(allowed_usernames)
+        const goodsIds = (Array.isArray(allowed_goods_ids) ? allowed_goods_ids : [])
+            .map((g) => Number(g))
+            .filter((g) => g > 0)
+
+        if (uScope === 1 && usernames.length === 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '请指定可使用该优惠码的用户' })
+        }
+        if (gScope === 1 && goodsIds.length === 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '请指定可使用该优惠码的商品' })
+        }
+
+        const dup = await db.query(
+            'SELECT id FROM coupons WHERE code = ? AND id != ? LIMIT 1',
+            [normalizedCode, id || 0]
+        )
+        if (dup && dup.length > 0) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '优惠码已存在' })
+        }
+
+        const time = Date.now()
+        const conn = await db.connect()
+
+        try {
+            await conn.beginTransaction()
+
+            let couponId = id ? Number(id) : null
+
+            if (!couponId) {
+                const [ins] = await conn.execute(
+                    `INSERT INTO coupons (
+                        code, name, discount_type, discount_value, user_scope, goods_scope,
+                        total_limit, per_user_limit, min_amount, start_time, end_time,
+                        state, create_user, create_time, update_time
+                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+                    [
+                        normalizedCode,
+                        name || '',
+                        discount_type,
+                        dValue,
+                        uScope,
+                        gScope,
+                        Number(total_limit || 0),
+                        Math.max(1, Number(per_user_limit || 1)),
+                        Number(min_amount || 0),
+                        start_time || null,
+                        end_time || null,
+                        Number(state),
+                        uuid,
+                        time,
+                        time
+                    ]
+                )
+                couponId = ins.insertId
+            } else {
+                await conn.execute(
+                    `UPDATE coupons SET
+                        code = ?, name = ?, discount_type = ?, discount_value = ?,
+                        user_scope = ?, goods_scope = ?, total_limit = ?, per_user_limit = ?,
+                        min_amount = ?, start_time = ?, end_time = ?, state = ?, update_time = ?
+                     WHERE id = ?`,
+                    [
+                        normalizedCode,
+                        name || '',
+                        discount_type,
+                        dValue,
+                        uScope,
+                        gScope,
+                        Number(total_limit || 0),
+                        Math.max(1, Number(per_user_limit || 1)),
+                        Number(min_amount || 0),
+                        start_time || null,
+                        end_time || null,
+                        Number(state),
+                        time,
+                        couponId
+                    ]
+                )
+            }
+
+            await conn.execute('DELETE FROM coupon_users WHERE coupon_id = ?', [couponId])
+            await conn.execute('DELETE FROM coupon_goods WHERE coupon_id = ?', [couponId])
+
+            if (uScope === 1) {
+                for (const username of usernames) {
+                    const [userRows] = await conn.execute(
+                        'SELECT uuid FROM users WHERE username = ? LIMIT 1',
+                        [username]
+                    )
+                    if (!userRows || userRows.length !== 1) {
+                        await conn.rollback()
+                        return res.json({
+                            ...BaseStdResponse.ERR,
+                            msg: `用户不存在:${username}`
+                        })
+                    }
+                    await conn.execute(
+                        'INSERT INTO coupon_users (coupon_id, user_uuid) VALUES (?, ?)',
+                        [couponId, userRows[0].uuid]
+                    )
+                }
+            }
+
+            if (gScope === 1) {
+                for (const goodsId of goodsIds) {
+                    await conn.execute(
+                        'INSERT INTO coupon_goods (coupon_id, goods_id) VALUES (?, ?)',
+                        [couponId, goodsId]
+                    )
+                }
+            }
+
+            await conn.commit()
+            return res.json({ ...BaseStdResponse.OK, id: couponId })
+        } catch (err) {
+            await conn.rollback()
+            this.logger.error(`保存优惠码失败: ${err.stack}`)
+            return res.json({ ...BaseStdResponse.ERR, msg: '保存优惠码失败' })
+        }
+    }
+}
+
+module.exports.SaveCoupon = SaveCoupon

+ 62 - 0
apis/Coupon/Validate.js

@@ -0,0 +1,62 @@
+const API = require('../../lib/API.js')
+const db = require('../../plugin/DataBase/db.js')
+const { BaseStdResponse } = require('../../BaseStdResponse.js')
+const AccessControl = require('../../lib/AccessControl.js')
+const { validateCoupon } = require('../../lib/CouponService.js')
+
+class ValidateCoupon extends API {
+    constructor() {
+        super()
+        this.setPath('/Coupon/Validate')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { uuid, session, code, goods_id } = req.body
+
+        if ([uuid, session, code, goods_id].some((v) => v === '' || v == null)) {
+            return res.json({ ...BaseStdResponse.MISSING_PARAMETER })
+        }
+
+        if (!(await AccessControl.checkSession(uuid, session))) {
+            return res.status(401).json({ ...BaseStdResponse.ACCESS_DENIED })
+        }
+
+        const goodsRows = await db.query(
+            'SELECT id, price, state, num FROM goods WHERE id = ? LIMIT 1',
+            [goods_id]
+        )
+        if (!goodsRows || goodsRows.length !== 1) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '商品不存在' })
+        }
+        const goods = goodsRows[0]
+        if (goods.state !== 1 || goods.num < 1) {
+            return res.json({ ...BaseStdResponse.ERR, msg: '商品不可购买' })
+        }
+
+        const result = await validateCoupon({
+            code,
+            userUuid: uuid,
+            goodsId: goods_id,
+            goodsPrice: goods.price
+        })
+
+        if (!result.ok) {
+            return res.json({ ...BaseStdResponse.ERR, msg: result.msg })
+        }
+
+        return res.json({
+            ...BaseStdResponse.OK,
+            data: {
+                code: result.code,
+                name: result.name,
+                displayDiscount: result.displayDiscount,
+                originalPrice: result.originalPrice,
+                discountAmount: result.discountAmount,
+                finalPrice: result.finalPrice
+            }
+        })
+    }
+}
+
+module.exports.ValidateCoupon = ValidateCoupon

+ 21 - 5
apis/Goods/Admin/AddGoods.js

@@ -24,9 +24,25 @@ class AddProduct extends API {
             price,
             price,
             num,
             num,
             lepao_count,
             lepao_count,
-            ic_count
+            ic_count,
+            icon,
+            description,
+            features
         } = req.body
         } = req.body
 
 
+        const goodsIcon = (icon && String(icon).trim()) ? String(icon).trim().slice(0, 16) : '🏃'
+        const goodsDesc = description != null ? String(description).trim().slice(0, 200) : ''
+        let goodsFeatures = '[]'
+        if (features != null && features !== '') {
+            const raw = typeof features === 'string' ? features : JSON.stringify(features)
+            try {
+                const arr = JSON.parse(raw)
+                goodsFeatures = JSON.stringify(Array.isArray(arr) ? arr.map(s => String(s).trim()).filter(Boolean).slice(0, 6) : [])
+            } catch {
+                goodsFeatures = '[]'
+            }
+        }
+
         if ([uuid, session, name, state, content, price, num, lepao_count, ic_count].some(value => value === '' || value === null || value === undefined))
         if ([uuid, session, name, state, content, price, num, lepao_count, ic_count].some(value => value === '' || value === null || value === undefined))
             return res.json({
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
                 ...BaseStdResponse.MISSING_PARAMETER
@@ -49,11 +65,11 @@ class AddProduct extends API {
         const time = new Date().getTime()
         const time = new Date().getTime()
 
 
         if (!id) {
         if (!id) {
-            sql = 'INSERT INTO goods (name, create_user, create_time, update_time, state, content, price, lepao_count, ic_count, num) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
-            r = await db.query(sql, [name, uuid, time, time, state, content, price, lepao_count, ic_count, num])
+            sql = 'INSERT INTO goods (name, create_user, create_time, update_time, state, content, price, lepao_count, ic_count, num, icon, description, features) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
+            r = await db.query(sql, [name, uuid, time, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures])
         } else {
         } else {
-            sql = 'UPDATE goods SET name = ?, update_user = ?, update_time = ?, state = ?,  content = ?, price = ?, lepao_count = ?, ic_count = ?, num = ? WHERE id = ?'
-            r = await db.query(sql, [name, uuid, time, state, content, price, lepao_count, ic_count, num, id])
+            sql = 'UPDATE goods SET name = ?, update_user = ?, update_time = ?, state = ?, content = ?, price = ?, lepao_count = ?, ic_count = ?, num = ?, icon = ?, description = ?, features = ? WHERE id = ?'
+            r = await db.query(sql, [name, uuid, time, state, content, price, lepao_count, ic_count, num, goodsIcon, goodsDesc, goodsFeatures, id])
         }
         }
 
 
         try {
         try {

+ 3 - 0
apis/Goods/Admin/GetGoods.js

@@ -48,6 +48,9 @@ class GetGoods extends API {
                 a.num,
                 a.num,
                 a.ic_count,
                 a.ic_count,
                 a.lepao_count,
                 a.lepao_count,
+                a.icon,
+                a.description,
+                a.features,
                 a.create_time,
                 a.create_time,
                 a.update_time,
                 a.update_time,
                 a.limit,
                 a.limit,

+ 1 - 0
apis/Goods/Admin/GetGoodsList.js

@@ -52,6 +52,7 @@ class GetGoodsList extends API {
                 a.num,
                 a.num,
                 a.ic_count,
                 a.ic_count,
                 a.lepao_count,
                 a.lepao_count,
+                a.icon,
                 a.views,
                 a.views,
                 a.create_time,
                 a.create_time,
                 a.update_time,
                 a.update_time,

+ 3 - 0
apis/Order/Admin/GetOrderDetail.js

@@ -39,6 +39,9 @@ class GetOrderDetail extends API {
                 o.create_time,
                 o.create_time,
                 o.pay_time,
                 o.pay_time,
                 o.price,
                 o.price,
+                o.original_price,
+                o.discount_amount,
+                o.coupon_code,
                 o.state,
                 o.state,
                 o.pay_id,
                 o.pay_id,
                 o.pay_type,
                 o.pay_type,

+ 45 - 5
apis/Order/CreateOrder.js

@@ -9,6 +9,7 @@ const config = require('../../config.json')
 const mq = require('../../plugin/mq')
 const mq = require('../../plugin/mq')
 const { mq: mqName } = require('../../plugin/mq/mqPrefix')
 const { mq: mqName } = require('../../plugin/mq/mqPrefix')
 const { insertLedgerRecord } = require('../../lib/Lepao/CountLedger')
 const { insertLedgerRecord } = require('../../lib/Lepao/CountLedger')
+const { validateCoupon, recordUsage, releaseUsageForOrder, roundMoney } = require('../../lib/CouponService')
 
 
 const ORDER_PAYMENT_QUEUE = mqName('order_payment_check')
 const ORDER_PAYMENT_QUEUE = mqName('order_payment_check')
 let orderPaymentWorkerStarted = false
 let orderPaymentWorkerStarted = false
@@ -100,6 +101,7 @@ async function pollOrderPaymentStatus(orderId, logger) {
                 [orderId]
                 [orderId]
             )
             )
             if (closeRes?.affectedRows > 0) {
             if (closeRes?.affectedRows > 0) {
+                await releaseUsageForOrder(orderId)
                 logger.info(`订单超时未支付,自动取消,订单号:${orderId}`)
                 logger.info(`订单超时未支付,自动取消,订单号:${orderId}`)
             }
             }
             return
             return
@@ -198,7 +200,7 @@ class CreateOrder extends API {
     }
     }
 
 
     async onRequest(req, res) {
     async onRequest(req, res) {
-        const { uuid, session, goods_id, pay_type } = req.body
+        const { uuid, session, goods_id, pay_type, coupon_code } = req.body
 
 
         if ([uuid, session, goods_id, pay_type].some(v => v === '' || v === null || v === undefined)) {
         if ([uuid, session, goods_id, pay_type].some(v => v === '' || v === null || v === undefined)) {
             return res.json({
             return res.json({
@@ -235,18 +237,56 @@ class CreateOrder extends API {
             const createTime = Date.now()
             const createTime = Date.now()
             const orderId = generateOrderId()
             const orderId = generateOrderId()
 
 
+            const originalPrice = roundMoney(goods.price)
+            let finalPrice = originalPrice
+            let discountAmount = 0
+            let couponId = null
+            let appliedCouponCode = null
+
+            if (coupon_code && String(coupon_code).trim()) {
+                const couponResult = await validateCoupon({
+                    code: coupon_code,
+                    userUuid: uuid,
+                    goodsId: goods_id,
+                    goodsPrice: goods.price
+                })
+                if (!couponResult.ok) {
+                    return res.json({ ...BaseStdResponse.ERR, msg: couponResult.msg })
+                }
+                finalPrice = couponResult.finalPrice
+                discountAmount = couponResult.discountAmount
+                couponId = couponResult.couponId
+                appliedCouponCode = couponResult.code
+            }
+
             const insertSql = `
             const insertSql = `
-                INSERT INTO orders (orderId, create_user, create_time, goods_id, price, pay_type)
-                VALUES (?, ?, ?, ?, ?, ?)
+                INSERT INTO orders (
+                    orderId, create_user, create_time, goods_id, price, pay_type,
+                    original_price, discount_amount, coupon_id, coupon_code
+                )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
             `
             `
             const result = await db.query(insertSql, [
             const result = await db.query(insertSql, [
-                orderId, uuid, createTime, goods_id, goods.price, pay_type
+                orderId,
+                uuid,
+                createTime,
+                goods_id,
+                finalPrice,
+                pay_type,
+                originalPrice,
+                discountAmount,
+                couponId,
+                appliedCouponCode
             ])
             ])
 
 
             const updateSql = 'UPDATE goods SET num = num - 1 WHERE id = ?'
             const updateSql = 'UPDATE goods SET num = num - 1 WHERE id = ?'
             await db.query(updateSql, [goods_id])
             await db.query(updateSql, [goods_id])
 
 
             if (result && result.affectedRows > 0) {
             if (result && result.affectedRows > 0) {
+                if (couponId) {
+                    await recordUsage(couponId, orderId, uuid, discountAmount)
+                }
+
                 const paymentConfig = config.pay || {}
                 const paymentConfig = config.pay || {}
 
 
                 if (!paymentConfig.pid || !paymentConfig.url || !paymentConfig.key || !paymentConfig.return_url) {
                 if (!paymentConfig.pid || !paymentConfig.url || !paymentConfig.key || !paymentConfig.return_url) {
@@ -270,7 +310,7 @@ class CreateOrder extends API {
                     notify_url: `${config.url}/Order/CallBack`,
                     notify_url: `${config.url}/Order/CallBack`,
                     return_url,
                     return_url,
                     name: goods.name,
                     name: goods.name,
-                    money: goods.price
+                    money: String(finalPrice)
                 }
                 }
 
 
                 const sign = generatePaymentSign(payParams, paymentConfig.key)
                 const sign = generatePaymentSign(payParams, paymentConfig.key)

+ 3 - 0
apis/Order/GetOrderDetail.js

@@ -19,6 +19,9 @@ class GetAccount extends API {
                 a.orderId,
                 a.orderId,
                 a.create_time,
                 a.create_time,
                 a.price,
                 a.price,
+                a.original_price,
+                a.discount_amount,
+                a.coupon_code,
                 a.state,
                 a.state,
                 a.pay_id,
                 a.pay_id,
                 a.pay_type,
                 a.pay_type,

+ 0 - 1
lib/API.js

@@ -100,7 +100,6 @@ class API {
 
 
             const requestId = req.headers['x-request-id'] || ''
             const requestId = req.headers['x-request-id'] || ''
             res.setHeader('X-Request-ID', requestId)
             res.setHeader('X-Request-ID', requestId)
-
             next()
             next()
         } catch (err) {
         } catch (err) {
             console.error('解密失败:', err)
             console.error('解密失败:', err)

+ 0 - 60
lib/AccessControl.js

@@ -33,66 +33,6 @@ class AccessControl {
     async ensurePermissionSchema() {
     async ensurePermissionSchema() {
         if (this.schemaReady) return
         if (this.schemaReady) return
 
 
-        await db.query(`
-            CREATE TABLE IF NOT EXISTS permission_points (
-                id INT NOT NULL AUTO_INCREMENT,
-                code VARCHAR(120) NOT NULL,
-                name VARCHAR(120) NOT NULL,
-                category VARCHAR(40) NOT NULL DEFAULT 'action',
-                scope_type VARCHAR(40) NOT NULL DEFAULT 'action',
-                page_route_name VARCHAR(120) DEFAULT NULL,
-                enabled TINYINT NOT NULL DEFAULT 1,
-                remark VARCHAR(255) DEFAULT '',
-                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-                PRIMARY KEY (id),
-                UNIQUE KEY uniq_permission_points_code (code)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
-        `)
-
-        await db.query(`
-            CREATE TABLE IF NOT EXISTS user_permission_points (
-                id INT NOT NULL AUTO_INCREMENT,
-                user_uuid VARCHAR(64) NOT NULL,
-                permission_code VARCHAR(120) NOT NULL,
-                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-                PRIMARY KEY (id),
-                UNIQUE KEY uniq_user_permission (user_uuid, permission_code),
-                KEY idx_user_permission_user_uuid (user_uuid),
-                KEY idx_user_permission_code (permission_code)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
-        `)
-
-        await db.query(`
-            CREATE TABLE IF NOT EXISTS user_basic_permission_denials (
-                id INT NOT NULL AUTO_INCREMENT,
-                user_uuid VARCHAR(64) NOT NULL,
-                permission_code VARCHAR(120) NOT NULL,
-                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-                PRIMARY KEY (id),
-                UNIQUE KEY uniq_user_basic_denial (user_uuid, permission_code),
-                KEY idx_user_basic_denial_user_uuid (user_uuid)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
-        `)
-
-        await db.query(`
-            CREATE TABLE IF NOT EXISTS permission_resource_rules (
-                id INT NOT NULL AUTO_INCREMENT,
-                resource_type VARCHAR(40) NOT NULL,
-                resource_key VARCHAR(180) NOT NULL,
-                api_method VARCHAR(16) DEFAULT NULL,
-                api_path VARCHAR(180) DEFAULT NULL,
-                required_codes TEXT NOT NULL,
-                enabled TINYINT NOT NULL DEFAULT 1,
-                remark VARCHAR(255) DEFAULT '',
-                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-                PRIMARY KEY (id),
-                UNIQUE KEY uniq_permission_resource (resource_type, resource_key),
-                KEY idx_permission_resource_api (api_method, api_path)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
-        `)
-
         for (const point of DEFAULT_PERMISSION_POINTS) {
         for (const point of DEFAULT_PERMISSION_POINTS) {
             await db.query(
             await db.query(
                 `INSERT INTO permission_points
                 `INSERT INTO permission_points

+ 166 - 0
lib/CouponService.js

@@ -0,0 +1,166 @@
+const db = require('../plugin/DataBase/db')
+
+function roundMoney(n) {
+    return Math.round(Number(n) * 100) / 100
+}
+
+function calcDiscount(price, discountType, discountValue) {
+    const p = Number(price)
+    let discount = 0
+    if (discountType === 'percent') {
+        const pct = Math.min(100, Math.max(0, Number(discountValue)))
+        discount = roundMoney((p * pct) / 100)
+    } else {
+        discount = roundMoney(Math.min(p, Math.max(0, Number(discountValue))))
+    }
+    let finalPrice = roundMoney(p - discount)
+    if (finalPrice < 0.01) finalPrice = 0.01
+    if (finalPrice > p) finalPrice = p
+    discount = roundMoney(p - finalPrice)
+    return { discountAmount: discount, finalPrice }
+}
+
+async function countActiveUsage(couponId, userUuid) {
+    const totalRows = await db.query(
+        `SELECT COUNT(*) AS cnt FROM coupon_usage cu
+         INNER JOIN orders o ON o.orderId = cu.order_id
+         WHERE cu.coupon_id = ? AND o.state IN (0, 1, 2)`,
+        [couponId]
+    )
+    const userRows = await db.query(
+        `SELECT COUNT(*) AS cnt FROM coupon_usage cu
+         INNER JOIN orders o ON o.orderId = cu.order_id
+         WHERE cu.coupon_id = ? AND cu.user_uuid = ? AND o.state IN (0, 1, 2)`,
+        [couponId, userUuid]
+    )
+    return {
+        total: Number(totalRows?.[0]?.cnt || 0),
+        perUser: Number(userRows?.[0]?.cnt || 0)
+    }
+}
+
+/**
+ * 校验优惠码并计算优惠后价格
+ */
+async function validateCoupon({ code, userUuid, goodsId, goodsPrice }) {
+    const normalizedCode = String(code || '').trim().toUpperCase()
+    if (!normalizedCode) {
+        return { ok: false, msg: '请输入优惠码' }
+    }
+
+    const rows = await db.query(
+        `SELECT * FROM coupons WHERE code = ? AND state = 1 LIMIT 1`,
+        [normalizedCode]
+    )
+    if (!rows || rows.length !== 1) {
+        return { ok: false, msg: '优惠码不存在或已失效' }
+    }
+
+    const coupon = rows[0]
+    const now = Date.now()
+
+    if (coupon.start_time && now < Number(coupon.start_time)) {
+        return { ok: false, msg: '优惠码尚未生效' }
+    }
+    if (coupon.end_time && now > Number(coupon.end_time)) {
+        return { ok: false, msg: '优惠码已过期' }
+    }
+
+    const price = Number(goodsPrice)
+    const minAmount = Number(coupon.min_amount || 0)
+    if (minAmount > 0 && price < minAmount) {
+        return { ok: false, msg: `订单满 ¥${minAmount} 才可使用该优惠码` }
+    }
+
+    if (Number(coupon.goods_scope) === 1) {
+        const goodsRows = await db.query(
+            'SELECT 1 FROM coupon_goods WHERE coupon_id = ? AND goods_id = ? LIMIT 1',
+            [coupon.id, goodsId]
+        )
+        if (!goodsRows || goodsRows.length === 0) {
+            return { ok: false, msg: '该优惠码不适用于当前商品' }
+        }
+    }
+
+    if (Number(coupon.user_scope) === 1) {
+        const userRows = await db.query(
+            'SELECT 1 FROM coupon_users WHERE coupon_id = ? AND user_uuid = ? LIMIT 1',
+            [coupon.id, userUuid]
+        )
+        if (!userRows || userRows.length === 0) {
+            return { ok: false, msg: '您暂无使用该优惠码的权限' }
+        }
+    }
+
+    const usage = await countActiveUsage(coupon.id, userUuid)
+    const totalLimit = Number(coupon.total_limit || 0)
+    const perUserLimit = Number(coupon.per_user_limit || 1)
+
+    if (totalLimit > 0 && usage.total >= totalLimit) {
+        return { ok: false, msg: '优惠码已达使用上限' }
+    }
+    if (usage.perUser >= perUserLimit) {
+        return { ok: false, msg: '您已达到该优惠码的使用次数上限' }
+    }
+
+    const { discountAmount, finalPrice } = calcDiscount(
+        price,
+        coupon.discount_type,
+        coupon.discount_value
+    )
+
+    if (discountAmount <= 0) {
+        return { ok: false, msg: '优惠码无效,未产生优惠' }
+    }
+
+    return {
+        ok: true,
+        couponId: coupon.id,
+        code: coupon.code,
+        name: coupon.name,
+        discountType: coupon.discount_type,
+        discountValue: Number(coupon.discount_value),
+        originalPrice: roundMoney(price),
+        discountAmount,
+        finalPrice,
+        displayDiscount:
+            coupon.discount_type === 'percent'
+                ? `${coupon.discount_value}% 折扣`
+                : `立减 ¥${coupon.discount_value}`
+    }
+}
+
+async function recordUsage(couponId, orderId, userUuid, discountAmount) {
+    const time = Date.now()
+    await db.query(
+        `INSERT INTO coupon_usage (coupon_id, order_id, user_uuid, discount_amount, create_time)
+         VALUES (?, ?, ?, ?, ?)`,
+        [couponId, orderId, userUuid, discountAmount, time]
+    )
+    await db.query('UPDATE coupons SET used_count = used_count + 1 WHERE id = ?', [couponId])
+}
+
+async function releaseUsageForOrder(orderId) {
+    const rows = await db.query(
+        'SELECT coupon_id FROM orders WHERE orderId = ? AND coupon_id IS NOT NULL LIMIT 1',
+        [orderId]
+    )
+    if (!rows || !rows[0]?.coupon_id) return
+
+    const couponId = rows[0].coupon_id
+    const del = await db.query('DELETE FROM coupon_usage WHERE order_id = ?', [orderId])
+    if (del?.affectedRows > 0) {
+        await db.query(
+            'UPDATE coupons SET used_count = GREATEST(used_count - 1, 0) WHERE id = ?',
+            [couponId]
+        )
+    }
+}
+
+module.exports = {
+    roundMoney,
+    calcDiscount,
+    validateCoupon,
+    recordUsage,
+    releaseUsageForOrder
+}

+ 16 - 4
lib/Server.js

@@ -10,6 +10,7 @@ const mq = require('../plugin/mq')
 const { mq: mqName } = require('../plugin/mq/mqPrefix')
 const { mq: mqName } = require('../plugin/mq/mqPrefix')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
 const OneBotV11 = require('../plugin/OneBot/OneBotV11')
 const OneBotV11 = require('../plugin/OneBot/OneBotV11')
+const AccessControl = require('./AccessControl')
 
 
 class SERVER {
 class SERVER {
     constructor() {
     constructor() {
@@ -89,6 +90,10 @@ class SERVER {
         }
         }
     }
     }
 
 
+    async initAccessControlSchema() {
+        await AccessControl.ensurePermissionSchema()
+    }
+
     loadAPIs(directory) {
     loadAPIs(directory) {
         const items = fs.readdirSync(directory)
         const items = fs.readdirSync(directory)
 
 
@@ -130,12 +135,19 @@ class SERVER {
         // 初始化数据库连接
         // 初始化数据库连接
         this.initDB().then(() => {
         this.initDB().then(() => {
             this.initMQ().then(() => {
             this.initMQ().then(() => {
-                this.initOneBot().then(() => {
-                    this.app.listen(this.port, () => {
-                        this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
+                this.initAccessControlSchema().then(() => {
+                    this.initOneBot().then(() => {
+                        this.app.listen(this.port, () => {
+                            this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
+                        })
+                    }).catch(err => {
+                        this.logger.error(`OneBot 初始化异常: ${err.message}`)
+                        this.app.listen(this.port, () => {
+                            this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
+                        })
                     })
                     })
                 }).catch(err => {
                 }).catch(err => {
-                    this.logger.error(`OneBot 初始化异常: ${err.message}`)
+                    this.logger.error(`权限模型初始化异常: ${err.message}`)
                     this.app.listen(this.port, () => {
                     this.app.listen(this.port, () => {
                         this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
                         this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
                     })
                     })