From d9c8388711cf84909512c2bd4a85f407163736a4 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Sat, 10 Oct 2026 09:22:46 +0800 Subject: [PATCH] feat(marketing): implement recharge tiers and coupon management across admin, server, and uniapp --- .../arco-design-pro-vite/src/locale/en-US.ts | 2 + .../arco-design-pro-vite/src/locale/zh-CN.ts | 2 + .../src/router/routes/modules/finance.ts | 20 + .../src/views/finance/coupons/index.vue | 371 ++++++++++++++++++ .../views/finance/recharge-tiers/index.vue | 296 ++++++++++++++ pwuniapp/common/api.js | 31 ++ pwuniapp/pages/user/index.vue | 38 +- .../src/core/marketing/marketing.service.ts | 104 +++++ server/src/db/mysql.ts | 62 +++ server/src/db/repository.ts | 248 ++++++++++++ server/src/index.ts | 188 +++++++++ server/tests/marketing.spec.ts | 139 +++++++ 12 files changed, 1494 insertions(+), 7 deletions(-) create mode 100644 admin/arco-design-pro-vite/src/views/finance/coupons/index.vue create mode 100644 admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue create mode 100644 server/src/core/marketing/marketing.service.ts create mode 100644 server/tests/marketing.spec.ts diff --git a/admin/arco-design-pro-vite/src/locale/en-US.ts b/admin/arco-design-pro-vite/src/locale/en-US.ts index 39545a1..bbeff0f 100644 --- a/admin/arco-design-pro-vite/src/locale/en-US.ts +++ b/admin/arco-design-pro-vite/src/locale/en-US.ts @@ -43,6 +43,8 @@ export default { 'menu.finance.records': 'Transaction Ledger', 'menu.finance.withdraw': 'Withdrawal Audit', 'menu.finance.config': 'Rate & Commission', + 'menu.finance.rechargeTiers': 'Recharge Tiers', + 'menu.finance.coupons': 'Coupons Management', 'menu.safety': 'Safety & Risk Control', 'menu.safety.places': 'Approved Venues', 'menu.safety.auditLog': 'Risk Intercept Log', diff --git a/admin/arco-design-pro-vite/src/locale/zh-CN.ts b/admin/arco-design-pro-vite/src/locale/zh-CN.ts index c7ac142..23347a6 100644 --- a/admin/arco-design-pro-vite/src/locale/zh-CN.ts +++ b/admin/arco-design-pro-vite/src/locale/zh-CN.ts @@ -43,6 +43,8 @@ export default { 'menu.finance.records': '资金交易流水', 'menu.finance.withdraw': '提现申请审核', 'menu.finance.config': '分销与抽成配置', + 'menu.finance.rechargeTiers': '充值赠送阶梯', + 'menu.finance.coupons': '平台优惠券管理', 'menu.safety': '履约安全与风控', 'menu.safety.places': '线下商圈场所库', 'menu.safety.auditLog': '场所与内容拦截日志', diff --git a/admin/arco-design-pro-vite/src/router/routes/modules/finance.ts b/admin/arco-design-pro-vite/src/router/routes/modules/finance.ts index 78a2def..2f0266e 100644 --- a/admin/arco-design-pro-vite/src/router/routes/modules/finance.ts +++ b/admin/arco-design-pro-vite/src/router/routes/modules/finance.ts @@ -42,6 +42,26 @@ const FINANCE: AppRouteRecordRaw = { roles: ['admin'], }, }, + { + path: 'recharge-tiers', + name: 'FinanceRechargeTiers', + component: () => import('@/views/finance/recharge-tiers/index.vue'), + meta: { + locale: 'menu.finance.rechargeTiers', + requiresAuth: true, + roles: ['admin'], + }, + }, + { + path: 'coupons', + name: 'FinanceCoupons', + component: () => import('@/views/finance/coupons/index.vue'), + meta: { + locale: 'menu.finance.coupons', + requiresAuth: true, + roles: ['admin'], + }, + }, ], }; diff --git a/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue b/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue new file mode 100644 index 0000000..c42487b --- /dev/null +++ b/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue @@ -0,0 +1,371 @@ + + + + 财务与分销管理 + 平台优惠券管理 + + + + + + + + 创建优惠券 + + + + 刷新 + + + + + + 在此处发布的优惠券可供用户在微信小程序端领券中心领取并在下单结算、组局报名支付时抵扣订单金额。支持全品类通用券与品类专属券(美食探店、KTV陪唱等)。 + + + + + + + + + {{ record.name }} + {{ record.description || '(未设置使用说明)' }} + + + + + + ¥{{ Number(record.discountAmount).toFixed(2) }} + + {{ Number(record.minSpend) > 0 ? `满 ¥${Number(record.minSpend).toFixed(2)} 可用` : '无门槛立减' }} + + + + + + + + {{ getCategoryLabel(record.categoryLimit) }} + + + + + + + + 已领 {{ record.receivedCount }} / 总量 {{ record.totalCount }} + + + + + + + + {{ record.validDays }} 天 + + + + + + handleToggleStatus(record, val)" + > + 上架 + 停发 + + + + + + + + + + 编辑 + + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 全品类通用 + 仅限美食探店组局 + 仅限KTV陪唱 + 仅限同城微醺/下午茶 + 仅限线上游戏电竞 + + + + + + + + + + 立即上架(小程序端立即可领) + 暂存下线 + + + + + + + + + + diff --git a/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue b/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue new file mode 100644 index 0000000..0c69c34 --- /dev/null +++ b/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue @@ -0,0 +1,296 @@ + + + + 财务与分销管理 + 充值赠送阶梯 + + + + + + + + 新增充值档位 + + + + 刷新 + + + + + + 此处配置的充值金额与赠送额度将即时同步至小程序端个人中心钱包充值界面。支持自定义金额档位、赠送金、推荐标签(如“热门畅享”、“尊享特惠”)及排序权重。 + + + + + + + + + ¥{{ Number(record.amount).toFixed(2) }} + + + + + + + + ¥{{ Number(record.bonusAmount).toFixed(2) }} + + 无加赠 + + + + + + {{ record.tag }} + - + + + + + + ¥{{ (Number(record.amount) + Number(record.bonusAmount)).toFixed(2) }} + + + + + + + + + + + + handleToggleStatus(record, val)" + > + 上架 + 下线 + + + + + + + + + + 编辑 + + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 立即上架(小程序端立即可选) + 暂存下线 + + + + + + + + + + diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index b7a590c..3fe9943 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -579,6 +579,37 @@ export async function getNavCategories() { return null; } +/** + * 获取钱包充值阶梯与赠送配置列表 (真实从 MySQL 读取) + */ +export async function getRechargeTiers() { + try { + const res = await http.get('/marketing/recharge-tiers'); + if (Array.isArray(res) && res.length > 0) { + return res; + } + } catch (err) { + console.warn('[API] 获取充值阶梯异常:', err.message); + } + return null; +} + +/** + * 获取可用优惠券列表 (支持品类过滤) + */ +export async function getMarketingCoupons(category) { + try { + const query = category ? `?category=${category}` : ''; + const res = await http.get(`/marketing/coupons${query}`); + if (Array.isArray(res) && res.length > 0) { + return res; + } + } catch (err) { + console.warn('[API] 获取营销优惠券异常:', err.message); + } + return null; +} + diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index fe8cb22..4268ab1 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -218,7 +218,7 @@ import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage } from '@/common/api.js'; +import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers } from '@/common/api.js'; const user = ref({ id: 1001, @@ -453,21 +453,45 @@ const viewWalletLog = async () => { } }; -const handleRecharge = () => { +const handleRecharge = async () => { + let tiers = []; + try { + const list = await getRechargeTiers(); + if (Array.isArray(list) && list.length > 0) { + tiers = list; + } + } catch (_) {} + + if (tiers.length === 0) { + tiers = [ + { amount: 50, bonusAmount: 0, tag: '基础充值' }, + { amount: 100, bonusAmount: 5, tag: '热门畅享' }, + { amount: 200, bonusAmount: 15, tag: '超值加赠' }, + { amount: 500, bonusAmount: 50, tag: '尊享特惠' } + ]; + } + + const itemList = tiers.map((t) => { + const bonus = Number(t.bonusAmount) > 0 ? ` (赠¥${Number(t.bonusAmount)})` : ''; + const tag = t.tag ? ` [${t.tag}]` : ''; + return `充值 ¥${Number(t.amount)}${bonus}${tag}`; + }); + uni.showActionSheet({ - itemList: ['快捷充值 ¥50', '快捷充值 ¥100', '快捷充值 ¥200', '快捷充值 ¥500'], + itemList, success: async (res) => { - const amounts = [50, 100, 200, 500]; - const amount = amounts[res.tapIndex] || 50; + const selected = tiers[res.tapIndex] || tiers[0]; + const amount = Number(selected.amount); try { const result = await rechargeBalance(1001, amount); if (result && result.balance) { user.value.balance = result.balance; } + const bonusMsg = Number(selected.bonusAmount) > 0 ? `,加赠 ¥${Number(selected.bonusAmount)} 已同步入账` : ''; uni.showToast({ - title: `充值 ¥${amount} 成功!`, + title: `充值 ¥${amount} 成功${bonusMsg}!`, icon: 'success', - duration: 1800 + duration: 2000 }); } catch (err) { uni.showToast({ diff --git a/server/src/core/marketing/marketing.service.ts b/server/src/core/marketing/marketing.service.ts new file mode 100644 index 0000000..efde3d8 --- /dev/null +++ b/server/src/core/marketing/marketing.service.ts @@ -0,0 +1,104 @@ +// 已读取AGENTS.md +import { ContentSecurityService } from '../security/content-security.service'; + +export interface RechargeTierItem { + id: number; + amount: number; + bonusAmount: number; + tag?: string; + sortOrder: number; + status: number; +} + +export interface CouponItem { + id: number; + name: string; + discountAmount: number; + minSpend: number; + validDays: number; + totalCount: number; + receivedCount: number; + categoryLimit: string; + description?: string; + status: number; +} + +export class MarketingService { + private tiers: RechargeTierItem[] = [ + { id: 1, amount: 50, bonusAmount: 0, tag: '基础充值', sortOrder: 1, status: 1 }, + { id: 2, amount: 100, bonusAmount: 5, tag: '热门畅享', sortOrder: 2, status: 1 }, + { id: 3, amount: 200, bonusAmount: 15, tag: '超值加赠', sortOrder: 3, status: 1 }, + { id: 4, amount: 500, bonusAmount: 50, tag: '尊享特惠', sortOrder: 4, status: 1 }, + { id: 5, amount: 1000, bonusAmount: 120, tag: '土豪专享', sortOrder: 5, status: 1 } + ]; + + private coupons: CouponItem[] = [ + { id: 1, name: '新用户注册专享立减券', discountAmount: 20, minSpend: 50, validDays: 15, totalCount: 2000, receivedCount: 128, categoryLimit: 'ALL', description: '全品类满50可用', status: 1 }, + { id: 2, name: '周末同城聚会AA抵扣券', discountAmount: 15, minSpend: 80, validDays: 7, totalCount: 1000, receivedCount: 85, categoryLimit: 'MEAL', description: '限美食探店使用', status: 1 }, + { id: 3, name: '工体K歌麦霸欢唱减免券', discountAmount: 30, minSpend: 120, validDays: 10, totalCount: 800, receivedCount: 62, categoryLimit: 'SING', description: '限KTV陪唱使用', status: 1 } + ]; + + private nextTierId = 6; + private nextCouponId = 4; + private secService = new ContentSecurityService(); + + // 充值阶梯 + getActiveTiers(): RechargeTierItem[] { + return this.tiers.filter((t) => t.status === 1).sort((a, b) => a.sortOrder - b.sortOrder); + } + + createTier(data: { amount: number; bonusAmount?: number; tag?: string; sortOrder?: number; status?: number }): RechargeTierItem { + if (!data.amount || data.amount <= 0) { + throw new Error('充值金额必须大于0'); + } + const item: RechargeTierItem = { + id: this.nextTierId++, + amount: Number(data.amount), + bonusAmount: Number(data.bonusAmount || 0), + tag: data.tag || '', + sortOrder: Number(data.sortOrder || 0), + status: data.status !== undefined ? Number(data.status) : 1 + }; + this.tiers.push(item); + return item; + } + + // 优惠券业务 + getActiveCoupons(category?: string): CouponItem[] { + return this.coupons + .filter((c) => c.status === 1) + .filter((c) => !category || category === 'ALL' || c.categoryLimit === 'ALL' || c.categoryLimit === category) + .sort((a, b) => b.discountAmount - a.discountAmount); + } + + createCoupon(data: { name: string; discountAmount: number; minSpend?: number; validDays?: number; totalCount?: number; categoryLimit?: string; description?: string; status?: number }): CouponItem { + if (!data.name || !data.name.trim()) { + throw new Error('优惠券名称不能为空'); + } + if (!data.discountAmount || data.discountAmount <= 0) { + throw new Error('减免面额必须大于0'); + } + + const sec = this.secService.checkText(data.name + ' ' + (data.description || '')); + if (!sec.pass) { + const err = new Error(sec.message); + (err as any).code = sec.code; + throw err; + } + + const item: CouponItem = { + id: this.nextCouponId++, + name: data.name.trim(), + discountAmount: Number(data.discountAmount), + minSpend: Number(data.minSpend || 0), + validDays: Number(data.validDays || 7), + totalCount: Number(data.totalCount || 1000), + receivedCount: 0, + categoryLimit: data.categoryLimit || 'ALL', + description: data.description || '', + status: data.status !== undefined ? Number(data.status) : 1 + }; + this.coupons.push(item); + return item; + } +} diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index d1a6356..65e4a1a 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -337,6 +337,41 @@ export async function initDatabase(): Promise { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // 18. 钱包充值阶梯与赠送配置表 + await conn.query(` + CREATE TABLE IF NOT EXISTS recharge_tiers ( + id INT PRIMARY KEY AUTO_INCREMENT, + amount DECIMAL(10,2) NOT NULL, + bonus_amount DECIMAL(10,2) DEFAULT 0.00, + tag VARCHAR(32) DEFAULT '', + sort_order INT DEFAULT 0, + status TINYINT DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_sort (sort_order), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + + // 19. 平台营销优惠券配置与发放表 + await conn.query(` + CREATE TABLE IF NOT EXISTS coupons ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + discount_amount DECIMAL(10,2) NOT NULL, + min_spend DECIMAL(10,2) NOT NULL DEFAULT 0.00, + valid_days INT NOT NULL DEFAULT 7, + total_count INT NOT NULL DEFAULT 1000, + received_count INT NOT NULL DEFAULT 0, + category_limit VARCHAR(64) DEFAULT 'ALL', + description VARCHAR(255) DEFAULT '', + status TINYINT DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + // 初始化配置单条记录 const [cfgRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_config WHERE id = 1'); if (cfgRows[0].count === 0) { @@ -564,4 +599,31 @@ async function seedInitialDataIfEmpty(conn: any): Promise { (10, 'more', '全部活动', 'gridAll', 'bg-blue', 'all', '/pages/activity/list?category=ALL', '', 10, 1); `); } + + // 15. 种子充值阶梯 + const [tierRows]: any = await conn.query('SELECT COUNT(*) as count FROM recharge_tiers'); + if (tierRows[0].count === 0) { + await conn.query(` + INSERT INTO recharge_tiers (id, amount, bonus_amount, tag, sort_order, status) + VALUES + (1, 50.00, 0.00, '基础充值', 1, 1), + (2, 100.00, 5.00, '热门畅享', 2, 1), + (3, 200.00, 15.00, '超值加赠', 3, 1), + (4, 500.00, 50.00, '尊享特惠', 4, 1), + (5, 1000.00, 120.00, '土豪专享', 5, 1); + `); + } + + // 16. 种子平台优惠券 + const [cpnRows]: any = await conn.query('SELECT COUNT(*) as count FROM coupons'); + if (cpnRows[0].count === 0) { + await conn.query(` + INSERT INTO coupons (id, name, discount_amount, min_spend, valid_days, total_count, received_count, category_limit, description, status) + VALUES + (1, '新用户注册专享立减券', 20.00, 50.00, 15, 2000, 128, 'ALL', '全品类陪玩及同城组局满50可用', 1), + (2, '周末同城聚会AA抵扣券', 15.00, 80.00, 7, 1000, 85, 'MEAL', '仅限同城美食探店组局活动使用', 1), + (3, '工体K歌麦霸欢唱减免券', 30.00, 120.00, 10, 800, 62, 'SING', '仅限KTV陪唱预约与活动使用', 1), + (4, '电竞开黑连胜冲星券', 10.00, 30.00, 30, 5000, 340, 'ALL', '线上游戏陪玩下单立减', 1); + `); + } } diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index e77d852..002a6e9 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -999,6 +999,254 @@ export class MysqlRepository { return res.affectedRows > 0; } + // 1. 充值阶梯 (Recharge Tiers) + async getRechargeTiers(): Promise { + const [rows]: any = await pool.query(` + SELECT + id, amount, bonus_amount as bonusAmount, tag, + sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM recharge_tiers + WHERE status = 1 + ORDER BY sort_order ASC, amount ASC + `); + return rows; + } + + async getAdminRechargeTiers(): Promise { + const [rows]: any = await pool.query(` + SELECT + id, amount, bonus_amount as bonusAmount, tag, + sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM recharge_tiers + ORDER BY sort_order ASC, amount ASC + `); + return rows; + } + + async createRechargeTier(data: { + amount: number; + bonusAmount?: number; + tag?: string; + sortOrder?: number; + status?: number; + }): Promise { + const amount = Number(data.amount); + const bonusAmount = Number(data.bonusAmount || 0); + const tag = data.tag || ''; + const sortOrder = Number(data.sortOrder || 0); + const status = data.status !== undefined ? Number(data.status) : 1; + + const [res]: any = await pool.query(` + INSERT INTO recharge_tiers (amount, bonus_amount, tag, sort_order, status) + VALUES (?, ?, ?, ?, ?) + `, [amount, bonusAmount, tag, sortOrder, status]); + + const newId = res.insertId; + const [rows]: any = await pool.query(` + SELECT + id, amount, bonus_amount as bonusAmount, tag, + sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM recharge_tiers + WHERE id = ? + `, [newId]); + return rows[0]; + } + + async updateRechargeTier(id: number | string, data: Partial<{ + amount: number; + bonusAmount: number; + tag: string; + sortOrder: number; + status: number; + }>): Promise { + const fields: string[] = []; + const values: any[] = []; + + if (data.amount !== undefined) { + fields.push('amount = ?'); + values.push(Number(data.amount)); + } + if (data.bonusAmount !== undefined) { + fields.push('bonus_amount = ?'); + values.push(Number(data.bonusAmount)); + } + if (data.tag !== undefined) { + fields.push('tag = ?'); + values.push(data.tag); + } + if (data.sortOrder !== undefined) { + fields.push('sort_order = ?'); + values.push(Number(data.sortOrder)); + } + if (data.status !== undefined) { + fields.push('status = ?'); + values.push(Number(data.status)); + } + + if (fields.length > 0) { + values.push(id); + await pool.query(`UPDATE recharge_tiers SET ${fields.join(', ')} WHERE id = ?`, values); + } + + const [rows]: any = await pool.query(` + SELECT + id, amount, bonus_amount as bonusAmount, tag, + sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM recharge_tiers + WHERE id = ? + `, [id]); + return rows[0] || null; + } + + async deleteRechargeTier(id: number | string): Promise { + const [res]: any = await pool.query('DELETE FROM recharge_tiers WHERE id = ?', [id]); + return res.affectedRows > 0; + } + + // 2. 优惠券 (Coupons) + async getCoupons(categoryLimit?: string): Promise { + let sql = ` + SELECT + id, name, discount_amount as discountAmount, min_spend as minSpend, + valid_days as validDays, total_count as totalCount, received_count as receivedCount, + category_limit as categoryLimit, description, status, + created_at as createdAt, updated_at as updatedAt + FROM coupons + WHERE status = 1 + `; + const params: any[] = []; + if (categoryLimit && categoryLimit !== 'ALL') { + sql += ' AND (category_limit = "ALL" OR category_limit = ?)'; + params.push(categoryLimit); + } + sql += ' ORDER BY discount_amount DESC, id ASC'; + + const [rows]: any = await pool.query(sql, params); + return rows; + } + + async getAdminCoupons(): Promise { + const [rows]: any = await pool.query(` + SELECT + id, name, discount_amount as discountAmount, min_spend as minSpend, + valid_days as validDays, total_count as totalCount, received_count as receivedCount, + category_limit as categoryLimit, description, status, + created_at as createdAt, updated_at as updatedAt + FROM coupons + ORDER BY id DESC + `); + return rows; + } + + async createCoupon(data: { + name: string; + discountAmount: number; + minSpend?: number; + validDays?: number; + totalCount?: number; + categoryLimit?: string; + description?: string; + status?: number; + }): Promise { + const name = data.name; + const discountAmount = Number(data.discountAmount); + const minSpend = Number(data.minSpend || 0); + const validDays = Number(data.validDays || 7); + const totalCount = Number(data.totalCount || 1000); + const categoryLimit = data.categoryLimit || 'ALL'; + const description = data.description || ''; + const status = data.status !== undefined ? Number(data.status) : 1; + + const [res]: any = await pool.query(` + INSERT INTO coupons (name, discount_amount, min_spend, valid_days, total_count, received_count, category_limit, description, status) + VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?) + `, [name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status]); + + const newId = res.insertId; + const [rows]: any = await pool.query(` + SELECT + id, name, discount_amount as discountAmount, min_spend as minSpend, + valid_days as validDays, total_count as totalCount, received_count as receivedCount, + category_limit as categoryLimit, description, status, + created_at as createdAt, updated_at as updatedAt + FROM coupons + WHERE id = ? + `, [newId]); + return rows[0]; + } + + async updateCoupon(id: number | string, data: Partial<{ + name: string; + discountAmount: number; + minSpend: number; + validDays: number; + totalCount: number; + categoryLimit: string; + description: string; + status: number; + }>): Promise { + const fields: string[] = []; + const values: any[] = []; + + if (data.name !== undefined) { + fields.push('name = ?'); + values.push(data.name); + } + if (data.discountAmount !== undefined) { + fields.push('discount_amount = ?'); + values.push(Number(data.discountAmount)); + } + if (data.minSpend !== undefined) { + fields.push('min_spend = ?'); + values.push(Number(data.minSpend)); + } + if (data.validDays !== undefined) { + fields.push('valid_days = ?'); + values.push(Number(data.validDays)); + } + if (data.totalCount !== undefined) { + fields.push('total_count = ?'); + values.push(Number(data.totalCount)); + } + if (data.categoryLimit !== undefined) { + fields.push('category_limit = ?'); + values.push(data.categoryLimit); + } + if (data.description !== undefined) { + fields.push('description = ?'); + values.push(data.description); + } + if (data.status !== undefined) { + fields.push('status = ?'); + values.push(Number(data.status)); + } + + if (fields.length > 0) { + values.push(id); + await pool.query(`UPDATE coupons SET ${fields.join(', ')} WHERE id = ?`, values); + } + + const [rows]: any = await pool.query(` + SELECT + id, name, discount_amount as discountAmount, min_spend as minSpend, + valid_days as validDays, total_count as totalCount, received_count as receivedCount, + category_limit as categoryLimit, description, status, + created_at as createdAt, updated_at as updatedAt + FROM coupons + WHERE id = ? + `, [id]); + return rows[0] || null; + } + + async deleteCoupon(id: number | string): Promise { + const [res]: any = await pool.query('DELETE FROM coupons WHERE id = ?', [id]); + return res.affectedRows > 0; + } + async getArbitrations(): Promise { diff --git a/server/src/index.ts b/server/src/index.ts index 50af214..5e1a349 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -863,6 +863,194 @@ app.delete('/api/admin/nav-categories/:id', async (req: Request, res: Response) } }); +// ===================== 营销中心 API ===================== + +// 1. 充值阶梯接口 +// 移动端:获取启用的充值阶梯 +app.get('/api/marketing/recharge-tiers', async (_req: Request, res: Response) => { + try { + const list = await repo.getRechargeTiers(); + res.json({ code: 20000, data: list }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理后台:获取全部充值阶梯 +app.get('/api/admin/recharge-tiers', async (_req: Request, res: Response) => { + try { + const list = await repo.getAdminRechargeTiers(); + res.json({ code: 20000, data: list }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理后台:新增充值阶梯 +app.post('/api/admin/recharge-tiers', async (req: Request, res: Response) => { + try { + const { amount, bonusAmount, tag, sortOrder, status } = req.body; + if (amount === undefined || Number(amount) <= 0) { + return res.status(400).json({ code: 40000, message: '充值金额必须大于0' }); + } + + const tier = await repo.createRechargeTier({ + amount: Number(amount), + bonusAmount: bonusAmount !== undefined ? Number(bonusAmount) : 0, + tag: tag || '', + sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0, + status: status !== undefined ? Number(status) : 1 + }); + + res.json({ code: 20000, data: tier, message: '充值阶梯创建成功' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台:修改充值阶梯 +app.put('/api/admin/recharge-tiers/:id', async (req: Request, res: Response) => { + try { + const tierId = String(req.params.id); + const { amount, bonusAmount, tag, sortOrder, status } = req.body; + + const updated = await repo.updateRechargeTier(tierId, { + amount: amount !== undefined ? Number(amount) : undefined, + bonusAmount: bonusAmount !== undefined ? Number(bonusAmount) : undefined, + tag, + sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined, + status: status !== undefined ? Number(status) : undefined + }); + + if (!updated) { + return res.status(404).json({ code: 40400, message: '充值阶梯不存在或已被删除' }); + } + + res.json({ code: 20000, data: updated, message: '充值阶梯修改成功' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台:删除充值阶梯 +app.delete('/api/admin/recharge-tiers/:id', async (req: Request, res: Response) => { + try { + const tierId = String(req.params.id); + const ok = await repo.deleteRechargeTier(tierId); + if (!ok) { + return res.status(404).json({ code: 40400, message: '充值阶梯不存在或已被删除' }); + } + res.json({ code: 20000, data: null, message: '充值阶梯已删除' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 2. 优惠券接口 +// 移动端:领券中心或下单结算可用券列表 +app.get('/api/marketing/coupons', async (req: Request, res: Response) => { + try { + const category = req.query.category ? String(req.query.category) : undefined; + const list = await repo.getCoupons(category); + res.json({ code: 20000, data: list }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理后台:获取全部优惠券 +app.get('/api/admin/coupons', async (_req: Request, res: Response) => { + try { + const list = await repo.getAdminCoupons(); + res.json({ code: 20000, data: list }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理后台:新增优惠券 +app.post('/api/admin/coupons', async (req: Request, res: Response) => { + try { + const { name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ code: 40000, message: '优惠券名称不能为空' }); + } + if (!discountAmount || Number(discountAmount) <= 0) { + return res.status(400).json({ code: 40000, message: '减免面额必须大于0' }); + } + + // 微信内容安全检测 + const sec = contentSecurityService.checkText(name + ' ' + (description || '')); + if (!sec.pass) { + return res.status(400).json({ code: sec.code, message: sec.message }); + } + + const coupon = await repo.createCoupon({ + name: name.trim(), + discountAmount: Number(discountAmount), + minSpend: minSpend !== undefined ? Number(minSpend) : 0, + validDays: validDays !== undefined ? Number(validDays) : 7, + totalCount: totalCount !== undefined ? Number(totalCount) : 1000, + categoryLimit: categoryLimit || 'ALL', + description: description || '', + status: status !== undefined ? Number(status) : 1 + }); + + res.json({ code: 20000, data: coupon, message: '优惠券创建成功' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台:修改优惠券 +app.put('/api/admin/coupons/:id', async (req: Request, res: Response) => { + try { + const couponId = String(req.params.id); + const { name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status } = req.body; + + if (name) { + const sec = contentSecurityService.checkText(name + ' ' + (description || '')); + if (!sec.pass) { + return res.status(400).json({ code: sec.code, message: sec.message }); + } + } + + const updated = await repo.updateCoupon(couponId, { + name: name ? name.trim() : undefined, + discountAmount: discountAmount !== undefined ? Number(discountAmount) : undefined, + minSpend: minSpend !== undefined ? Number(minSpend) : undefined, + validDays: validDays !== undefined ? Number(validDays) : undefined, + totalCount: totalCount !== undefined ? Number(totalCount) : undefined, + categoryLimit, + description, + status: status !== undefined ? Number(status) : undefined + }); + + if (!updated) { + return res.status(404).json({ code: 40400, message: '优惠券不存在或已被删除' }); + } + + res.json({ code: 20000, data: updated, message: '优惠券修改成功' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台:删除优惠券 +app.delete('/api/admin/coupons/:id', async (req: Request, res: Response) => { + try { + const couponId = String(req.params.id); + const ok = await repo.deleteCoupon(couponId); + if (!ok) { + return res.status(404).json({ code: 40400, message: '优惠券不存在或已被删除' }); + } + + res.json({ code: 20000, data: null, message: '优惠券已删除' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + // 创建新订单 (落盘 MySQL) app.post('/api/orders', async (req: Request, res: Response) => { try { diff --git a/server/tests/marketing.spec.ts b/server/tests/marketing.spec.ts new file mode 100644 index 0000000..d2842a2 --- /dev/null +++ b/server/tests/marketing.spec.ts @@ -0,0 +1,139 @@ +// 已读取AGENTS.md +import { MarketingService } from '../src/core/marketing/marketing.service'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('Marketing Management System (TDD 充值赠送阶梯与通用优惠券管理套件)', () => { + let service: MarketingService; + const rootDir = path.resolve(__dirname, '../../'); + const serverIndexPath = path.join(rootDir, 'server/src/index.ts'); + const mysqlPath = path.join(rootDir, 'server/src/db/mysql.ts'); + const repoPath = path.join(rootDir, 'server/src/db/repository.ts'); + + beforeEach(() => { + service = new MarketingService(); + }); + + describe('1. 充值阶梯与赠送业务逻辑校验', () => { + it('默认初始加载 5 个经典充值阶梯且按 sortOrder 正序排序', () => { + const tiers = service.getActiveTiers(); + expect(tiers.length).toBe(5); + expect(tiers[0].amount).toBe(50); + expect(tiers[0].bonusAmount).toBe(0); + expect(tiers[1].amount).toBe(100); + expect(tiers[1].bonusAmount).toBe(5); + expect(tiers[3].amount).toBe(500); + expect(tiers[3].bonusAmount).toBe(50); + }); + + it('成功创建新充值赠送阶梯', () => { + const tier = service.createTier({ + amount: 2000, + bonusAmount: 300, + tag: '超级VIP', + sortOrder: 6, + status: 1 + }); + expect(tier.id).toBe(6); + expect(tier.amount).toBe(2000); + expect(tier.bonusAmount).toBe(300); + }); + + it('充值金额小于等于 0 时应被拒绝', () => { + expect(() => { + service.createTier({ + amount: 0, + bonusAmount: 10 + }); + }).toThrow('充值金额必须大于0'); + }); + }); + + describe('2. 平台营销优惠券业务校验', () => { + it('默认加载优惠券种子且支持按品类过滤', () => { + const allCoupons = service.getActiveCoupons(); + expect(allCoupons.length).toBe(3); + + const mealCoupons = service.getActiveCoupons('MEAL'); + // 包含 ALL 和 MEAL 专属 + expect(mealCoupons.every((c) => c.categoryLimit === 'ALL' || c.categoryLimit === 'MEAL')).toBe(true); + }); + + it('成功创建合规优惠券', () => { + const coupon = service.createCoupon({ + name: '国庆特惠同城微醺立减券', + discountAmount: 25, + minSpend: 60, + categoryLimit: 'DRINK', + description: '望京跳海酒馆微醺可用' + }); + expect(coupon.id).toBe(4); + expect(coupon.discountAmount).toBe(25); + }); + + it('优惠券名称为空或减免金额无效时应被拒绝', () => { + expect(() => { + service.createCoupon({ + name: '', + discountAmount: 20 + }); + }).toThrow('优惠券名称不能为空'); + + expect(() => { + service.createCoupon({ + name: '测试券', + discountAmount: -5 + }); + }).toThrow('减免面额必须大于0'); + }); + + it('优惠券名称或描述涉嫌违规敏感词应触发微信内容安全拦截', () => { + expect(() => { + service.createCoupon({ + name: '私下转账免手续费优惠大促', + discountAmount: 50 + }); + }).toThrow(); + }); + }); + + describe('3. 数据库与 API 架构持久化门禁校验', () => { + it('MySQL 数据库必须包含 recharge_tiers 与 coupons 表定义及种子数据', () => { + const mysqlContent = fs.readFileSync(mysqlPath, 'utf-8'); + expect(mysqlContent).toContain('CREATE TABLE IF NOT EXISTS recharge_tiers'); + expect(mysqlContent).toContain('CREATE TABLE IF NOT EXISTS coupons'); + expect(mysqlContent).toContain('INSERT INTO recharge_tiers'); + expect(mysqlContent).toContain('INSERT INTO coupons'); + }); + + it('Repository 数据仓储层必须实现完整的充值阶梯与优惠券 CRUD 方法', () => { + const repoContent = fs.readFileSync(repoPath, 'utf-8'); + expect(repoContent).toContain('async getRechargeTiers(): Promise'); + expect(repoContent).toContain('async createRechargeTier('); + expect(repoContent).toContain('async updateRechargeTier('); + expect(repoContent).toContain('async deleteRechargeTier('); + expect(repoContent).toContain('async getCoupons('); + expect(repoContent).toContain('async createCoupon('); + expect(repoContent).toContain('async updateCoupon('); + expect(repoContent).toContain('async deleteCoupon('); + }); + + it('server/src/index.ts 必须注册完整的营销 API 路由', () => { + const indexContent = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(indexContent).toContain("app.get('/api/marketing/recharge-tiers'"); + expect(indexContent).toContain("app.get('/api/admin/recharge-tiers'"); + expect(indexContent).toContain("app.post('/api/admin/recharge-tiers'"); + expect(indexContent).toContain("app.get('/api/marketing/coupons'"); + expect(indexContent).toContain("app.get('/api/admin/coupons'"); + expect(indexContent).toContain("app.post('/api/admin/coupons'"); + }); + + it('代码文件必须严格遵循零 Emoji 门禁规范', () => { + const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; + const serviceContent = fs.readFileSync(path.join(rootDir, 'server/src/core/marketing/marketing.service.ts'), 'utf-8'); + const testContent = fs.readFileSync(__filename, 'utf-8'); + expect(emojiRegex.test(serviceContent)).toBe(false); + expect(emojiRegex.test(testContent)).toBe(false); + }); + }); +});