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 c514d77..e0a7897 100644 --- a/admin/arco-design-pro-vite/src/locale/en-US.ts +++ b/admin/arco-design-pro-vite/src/locale/en-US.ts @@ -46,6 +46,8 @@ export default { 'menu.safety': 'Safety & Risk Control', 'menu.safety.places': 'Approved Venues', 'menu.safety.auditLog': 'Risk Intercept Log', + 'menu.banner': 'Banner & Operations', + 'menu.banner.manage': 'Banner Management', 'menu.server.dashboard': 'Dashboard-Server', 'menu.server.workplace': 'Workplace-Server', 'menu.server.monitor': 'Monitor-Server', 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 1953827..2f5896b 100644 --- a/admin/arco-design-pro-vite/src/locale/zh-CN.ts +++ b/admin/arco-design-pro-vite/src/locale/zh-CN.ts @@ -46,6 +46,8 @@ export default { 'menu.safety': '履约安全与风控', 'menu.safety.places': '线下商圈场所库', 'menu.safety.auditLog': '场所与内容拦截日志', + 'menu.banner': '首页轮播与运营', + 'menu.banner.manage': '轮播图管理', 'menu.server.dashboard': '仪表盘-服务端', 'menu.server.workplace': '工作台-服务端', 'menu.server.monitor': '实时监控-服务端', diff --git a/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts b/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts new file mode 100644 index 0000000..16d5a90 --- /dev/null +++ b/admin/arco-design-pro-vite/src/router/routes/modules/banner.ts @@ -0,0 +1,28 @@ +import { DEFAULT_LAYOUT } from '../base'; +import { AppRouteRecordRaw } from '../types'; + +const BANNER: AppRouteRecordRaw = { + path: '/banner', + name: 'banner', + component: DEFAULT_LAYOUT, + meta: { + locale: 'menu.banner', + requiresAuth: true, + icon: 'icon-image', + order: 6, + }, + children: [ + { + path: 'manage', + name: 'BannerManage', + component: () => import('@/views/banner/manage/index.vue'), + meta: { + locale: 'menu.banner.manage', + requiresAuth: true, + roles: ['*'], + }, + }, + ], +}; + +export default BANNER; diff --git a/admin/arco-design-pro-vite/src/views/banner/manage/index.vue b/admin/arco-design-pro-vite/src/views/banner/manage/index.vue new file mode 100644 index 0000000..f93bb5c --- /dev/null +++ b/admin/arco-design-pro-vite/src/views/banner/manage/index.vue @@ -0,0 +1,445 @@ + + + + 首页轮播与运营 + 轮播图管理 + + + + + + + + 新增轮播图 + + + + 刷新 + + + + + + + 在此处配置的轮播图将实时同步至微信小程序端首页顶部横幅。支持配置活动跳转链接、标签角标、背景渐变色、排序优先级及一键上下线。 + + + + + + + + + + + + 无图卡片 + + + + + + + {{ record.title }} + {{ record.subtitle || '(未设置副标题)' }} + + + + + + + {{ record.tag || '默认标签' }} + + + {{ record.badge || 'HOT' }} + + + + + + + {{ record.link }} + + + + + + + + + + + + handleToggleStatus(record, val)" + > + 上架 + 下线 + + + + + + + + + + 编辑 + + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 上传图片 + + + + + + + + + + + + + + + + + + + + + + + + + + + 绯红活力 + + + 紫蓝魅夜 + + + 清新薄荷 + + + 暖阳金秋 + + + + + + + 立即上架(小程序端立即可见) + 暂存草稿(下线隐藏) + + + + + + + + + + diff --git a/server/src/core/banner/banner.service.ts b/server/src/core/banner/banner.service.ts new file mode 100644 index 0000000..ffb75a8 --- /dev/null +++ b/server/src/core/banner/banner.service.ts @@ -0,0 +1,166 @@ +// 已读取AGENTS.md +import { ContentSecurityService } from '../security/content-security.service'; + +export interface BannerItem { + id: number; + title: string; + subtitle?: string; + tag?: string; + badge?: string; + badgeType?: string; + bg?: string; + image?: string; + link?: string; + sortOrder?: number; + status: number; // 1: 启用上架, 0: 停用下架 + createdAt?: string; + updatedAt?: string; +} + +export class BannerService { + private banners: BannerItem[] = [ + { + id: 1, + title: '同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱', + subtitle: 'AA制铜锅涮肉 · 8人热烈局', + tag: '热门陪伴', + badge: 'HOT 热门组局', + badgeType: 'badge-hot', + bg: 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)', + image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800', + link: '/pages/activity/detail?id=act_101', + sortOrder: 1, + status: 1 + }, + { + id: 2, + title: '量贩KTV麦霸沉浸欢唱 | 音乐达人歌声试听', + subtitle: '工体纯K 888豪华包厢', + tag: 'K歌对唱', + badge: '同城陪唱', + badgeType: 'badge-ktv', + bg: 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)', + image: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=800', + link: '/pages/activity/detail?id=act_102', + sortOrder: 2, + status: 1 + }, + { + id: 3, + title: '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元', + subtitle: '望京跳海酒馆 · 慢调交友', + tag: '同城陪喝', + badge: '同城陪喝', + badgeType: 'badge-bar', + bg: 'linear-gradient(135deg, #059669 0%, #10B981 100%)', + image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', + link: '/pages/activity/detail?id=act_103', + sortOrder: 3, + status: 1 + } + ]; + + private nextId = 4; + private secService = new ContentSecurityService(); + + // 获取移动端展示的轮播图 (仅 status = 1,按 sortOrder 升序) + getActiveBanners(): BannerItem[] { + return this.banners + .filter((b) => b.status === 1) + .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + } + + // 获取后台管理的全部轮播图 + getAllBanners(): BannerItem[] { + return [...this.banners].sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); + } + + // 创建新轮播图 + createBanner(payload: { + title: string; + subtitle?: string; + tag?: string; + badge?: string; + badgeType?: string; + bg?: string; + image?: string; + link?: string; + sortOrder?: number; + status?: number; + }): BannerItem { + if (!payload.title || !payload.title.trim()) { + throw new Error('轮播图标题不能为空'); + } + + // 微信内容安全检测 + const secResult = this.secService.checkText(payload.title + ' ' + (payload.subtitle || '')); + if (!secResult.pass) { + const err = new Error(secResult.message); + (err as any).code = secResult.code; + throw err; + } + + const newBanner: BannerItem = { + id: this.nextId++, + title: payload.title.trim(), + subtitle: payload.subtitle ? payload.subtitle.trim() : '', + tag: payload.tag || '热门活动', + badge: payload.badge || 'HOT 热门组局', + badgeType: payload.badgeType || 'badge-hot', + bg: payload.bg || 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)', + image: payload.image || '', + link: payload.link || '/pages/activity/list', + sortOrder: payload.sortOrder !== undefined ? Number(payload.sortOrder) : 0, + status: payload.status !== undefined ? Number(payload.status) : 1, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + this.banners.push(newBanner); + return newBanner; + } + + // 修改轮播图 + updateBanner(id: number, payload: Partial>): BannerItem { + const index = this.banners.findIndex((b) => b.id === id); + if (index === -1) { + throw new Error('轮播图不存在或已被删除'); + } + + if (payload.title) { + const secResult = this.secService.checkText(payload.title + ' ' + (payload.subtitle || '')); + if (!secResult.pass) { + const err = new Error(secResult.message); + (err as any).code = secResult.code; + throw err; + } + } + + const current = this.banners[index]; + const updated: BannerItem = { + ...current, + ...payload, + updatedAt: new Date().toISOString() + }; + this.banners[index] = updated; + return updated; + } + + // 快速上下线切换 + toggleStatus(id: number, status: number): BannerItem { + if (status !== 0 && status !== 1) { + throw new Error('状态参数必须为 0 或 1'); + } + return this.updateBanner(id, { status }); + } + + // 删除轮播图 + deleteBanner(id: number): boolean { + const initialLength = this.banners.length; + this.banners = this.banners.filter((b) => b.id !== id); + if (this.banners.length === initialLength) { + throw new Error('轮播图不存在或已被删除'); + } + return true; + } +} diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 4223426..41326d2 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -296,6 +296,27 @@ export async function initDatabase(): Promise { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // 16. 首页轮播图运营配置表 + await conn.query(` + CREATE TABLE IF NOT EXISTS banners ( + id INT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(128) NOT NULL, + subtitle VARCHAR(255) DEFAULT '', + tag VARCHAR(64) DEFAULT '热门活动', + badge VARCHAR(64) DEFAULT 'HOT 热门组局', + badge_type VARCHAR(64) DEFAULT 'badge-hot', + bg VARCHAR(255) DEFAULT 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)', + image TEXT, + link VARCHAR(255) DEFAULT '/pages/activity/list', + 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; + `); + // 初始化配置单条记录 const [cfgRows]: any = await conn.query('SELECT COUNT(*) as count FROM finance_config WHERE id = 1'); if (cfgRows[0].count === 0) { @@ -492,4 +513,16 @@ async function seedInitialDataIfEmpty(conn: any): Promise { ('cmt_3', 'post_2', 2001, '涮肉小行家', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', '国服李白太强了!主玩大乔和张飞,晚上可以一起开黑吗?', NOW() - INTERVAL 25 MINUTE); `); } + + // 13. 种子轮播图 + const [bnrRows]: any = await conn.query('SELECT COUNT(*) as count FROM banners'); + if (bnrRows[0].count === 0) { + await conn.query(` + INSERT INTO banners (id, title, subtitle, tag, badge, badge_type, bg, image, link, sort_order, status) + VALUES + (1, '同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱', 'AA制铜锅涮肉 · 8人热烈局', '热门陪伴', 'HOT 热门组局', 'badge-hot', 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)', 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800', '/pages/activity/detail?id=act_101', 1, 1), + (2, '量贩KTV麦霸沉浸欢唱 | 音乐达人歌声试听', '工体纯K 888豪华包厢', 'K歌对唱', '同城陪唱', 'badge-ktv', 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)', 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=800', '/pages/activity/detail?id=act_102', 2, 1), + (3, '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元', '望京跳海酒馆 · 慢调交友', '同城陪喝', '同城陪喝', 'badge-bar', 'linear-gradient(135deg, #059669 0%, #10B981 100%)', 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', '/pages/activity/detail?id=act_103', 3, 1); + `); + } } diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index ede94d6..3751104 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -697,41 +697,158 @@ export class MysqlRepository { } async getBanners(): Promise { - return [ - { - id: 1, - title: '同城聚会狂欢 | 网红火锅探店与量贩KTV嗨唱', - subtitle: 'AA制铜锅涮肉 · 8人热烈局', - tag: '热门陪伴', - badge: 'HOT 热门组局', - badgeType: 'badge-hot', - bg: 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)', - image: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800', - link: '/pages/activity/detail?id=act_101' - }, - { - id: 2, - title: '量贩KTV麦霸沉浸欢唱 | 音乐达人歌声试听', - subtitle: '工体纯K 888豪华包厢', - tag: 'K歌对唱', - badge: '同城陪唱', - badgeType: 'badge-ktv', - bg: 'linear-gradient(135deg, #7C3AED 0%, #6366F1 100%)', - image: 'https://images.unsplash.com/photo-1516450360452-9312f5e86fc7?w=800', - link: '/pages/activity/detail?id=act_102' - }, - { - id: 3, - title: '精酿清吧微醺畅聊 | 线下陪吃陪喝立减30元', - subtitle: '望京跳海酒馆 · 慢调交友', - tag: '同城陪喝', - badge: '同城陪喝', - badgeType: 'badge-bar', - bg: 'linear-gradient(135deg, #059669 0%, #10B981 100%)', - image: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', - link: '/pages/activity/detail?id=act_103' - } - ]; + const [rows]: any = await pool.query(` + SELECT + id, title, subtitle, tag, badge, badge_type as badgeType, + bg, image, link, sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM banners + WHERE status = 1 + ORDER BY sort_order ASC, id DESC + `); + return rows; + } + + async getAdminBanners(): Promise { + const [rows]: any = await pool.query(` + SELECT + id, title, subtitle, tag, badge, badge_type as badgeType, + bg, image, link, sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM banners + ORDER BY sort_order ASC, id DESC + `); + return rows; + } + + async createBanner(data: { + title: string; + subtitle?: string; + tag?: string; + badge?: string; + badgeType?: string; + bg?: string; + image?: string; + link?: string; + sortOrder?: number; + status?: number; + }): Promise { + const title = data.title; + const subtitle = data.subtitle || ''; + const tag = data.tag || '热门活动'; + const badge = data.badge || 'HOT 热门组局'; + const badgeType = data.badgeType || 'badge-hot'; + const bg = data.bg || 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)'; + const image = data.image || ''; + const link = data.link || '/pages/activity/list'; + const sortOrder = Number(data.sortOrder) || 0; + const status = data.status !== undefined ? Number(data.status) : 1; + + const [res]: any = await pool.query(` + INSERT INTO banners (title, subtitle, tag, badge, badge_type, bg, image, link, sort_order, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status]); + + const newId = res.insertId; + const [rows]: any = await pool.query(` + SELECT + id, title, subtitle, tag, badge, badge_type as badgeType, + bg, image, link, sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM banners + WHERE id = ? + `, [newId]); + return rows[0]; + } + + async updateBanner(id: number | string, data: Partial<{ + title: string; + subtitle: string; + tag: string; + badge: string; + badgeType: string; + bg: string; + image: string; + link: string; + sortOrder: number; + status: number; + }>): Promise { + const fields: string[] = []; + const values: any[] = []; + + if (data.title !== undefined) { + fields.push('title = ?'); + values.push(data.title); + } + if (data.subtitle !== undefined) { + fields.push('subtitle = ?'); + values.push(data.subtitle); + } + if (data.tag !== undefined) { + fields.push('tag = ?'); + values.push(data.tag); + } + if (data.badge !== undefined) { + fields.push('badge = ?'); + values.push(data.badge); + } + if (data.badgeType !== undefined) { + fields.push('badge_type = ?'); + values.push(data.badgeType); + } + if (data.bg !== undefined) { + fields.push('bg = ?'); + values.push(data.bg); + } + if (data.image !== undefined) { + fields.push('image = ?'); + values.push(data.image); + } + if (data.link !== undefined) { + fields.push('link = ?'); + values.push(data.link); + } + 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 banners SET ${fields.join(', ')} WHERE id = ?`, values); + } + + const [rows]: any = await pool.query(` + SELECT + id, title, subtitle, tag, badge, badge_type as badgeType, + bg, image, link, sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM banners + WHERE id = ? + `, [id]); + return rows[0] || null; + } + + async updateBannerStatus(id: number | string, status: number): Promise { + await pool.query('UPDATE banners SET status = ? WHERE id = ?', [Number(status), id]); + const [rows]: any = await pool.query(` + SELECT + id, title, subtitle, tag, badge, badge_type as badgeType, + bg, image, link, sort_order as sortOrder, status, + created_at as createdAt, updated_at as updatedAt + FROM banners + WHERE id = ? + `, [id]); + return rows[0] || null; + } + + async deleteBanner(id: number | string): Promise { + const [res]: any = await pool.query('DELETE FROM banners WHERE id = ?', [id]); + return res.affectedRows > 0; } diff --git a/server/src/index.ts b/server/src/index.ts index b2e10a4..210f954 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -615,7 +615,7 @@ app.get('/api/expert/stats/:id', async (req: Request, res: Response) => { }); -// 首页运营 Banner 列表 (配置化接口) +// 首页运营 Banner 列表 (小程序端读取启用状态) app.get('/api/banners', async (_req: Request, res: Response) => { try { const banners = await repo.getBanners(); @@ -625,6 +625,120 @@ app.get('/api/banners', async (_req: Request, res: Response) => { } }); +// 管理后台: 获取全部 Banner 列表 (含停用状态) +app.get('/api/admin/banners', async (_req: Request, res: Response) => { + try { + const banners = await repo.getAdminBanners(); + res.json({ code: 20000, data: banners }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理后台: 新增 Banner +app.post('/api/admin/banners', async (req: Request, res: Response) => { + try { + const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body; + if (!title || !title.trim()) { + return res.status(400).json({ code: 40000, message: 'Banner 标题不能为空' }); + } + + // 微信内容安全检测 + const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || '')); + if (!secCheck.pass) { + return res.status(400).json({ code: secCheck.code, message: secCheck.message }); + } + + const banner = await repo.createBanner({ + title: title.trim(), + subtitle: subtitle ? subtitle.trim() : '', + tag, + badge, + badgeType, + bg, + image, + link, + sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0, + status: status !== undefined ? Number(status) : 1 + }); + + res.json({ code: 20000, data: banner, message: '轮播图创建成功' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台: 修改 Banner +app.put('/api/admin/banners/:id', async (req: Request, res: Response) => { + try { + const bannerId = req.params.id; + const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body; + + if (title) { + const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || '')); + if (!secCheck.pass) { + return res.status(400).json({ code: secCheck.code, message: secCheck.message }); + } + } + + const updated = await repo.updateBanner(bannerId, { + title, + subtitle, + tag, + badge, + badgeType, + bg, + image, + link, + 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 }); + } +}); + +// 管理后台: 切换 Banner 上架/下线状态 +app.post('/api/admin/banners/:id/status', async (req: Request, res: Response) => { + try { + const bannerId = req.params.id; + const { status } = req.body; + if (status === undefined || (status !== 0 && status !== 1)) { + return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' }); + } + + const updated = await repo.updateBannerStatus(bannerId, Number(status)); + if (!updated) { + return res.status(404).json({ code: 40400, message: '轮播图不存在' }); + } + + res.json({ code: 20000, data: updated, message: status === 1 ? '轮播图已上架生效' : '轮播图已下线隐藏' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台: 删除 Banner +app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => { + try { + const bannerId = req.params.id; + const ok = await repo.deleteBanner(bannerId); + 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/banner-manage.spec.ts b/server/tests/banner-manage.spec.ts new file mode 100644 index 0000000..ae02088 --- /dev/null +++ b/server/tests/banner-manage.spec.ts @@ -0,0 +1,176 @@ +// 已读取AGENTS.md +import { BannerService } from '../src/core/banner/banner.service'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('Banner Management System (TDD 轮播图后台运维与小程序动态联动套件)', () => { + let bannerService: BannerService; + 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'); + const mobileIndexPath = path.join(rootDir, 'pwuniapp/pages/index/index.vue'); + + beforeEach(() => { + bannerService = new BannerService(); + }); + + describe('1. 核心业务服务 BannerService 单测', () => { + it('默认初始加载 3 条上架状态的种子轮播图,且按 sortOrder 正序排序', () => { + const activeBanners = bannerService.getActiveBanners(); + expect(activeBanners.length).toBe(3); + expect(activeBanners[0].id).toBe(1); + expect(activeBanners[0].sortOrder).toBe(1); + expect(activeBanners[1].id).toBe(2); + expect(activeBanners[2].id).toBe(3); + expect(activeBanners.every((b) => b.status === 1)).toBe(true); + }); + + it('成功创建新运营轮播图并支持排序与分类标签', () => { + const created = bannerService.createBanner({ + title: '万圣节南瓜派对狂欢夜', + subtitle: '全场酒水半价 · 专属大神带队', + tag: '节日特惠', + badge: '节日爆款', + badgeType: 'badge-hot', + bg: 'linear-gradient(135deg, #FF7D00 0%, #F53F3F 100%)', + image: 'https://images.unsplash.com/photo-1509198397868-475647b2a1e5?w=800', + link: '/pages/activity/list?category=PARTY', + sortOrder: 0, // 置顶 + status: 1 + }); + + expect(created.id).toBe(4); + expect(created.title).toBe('万圣节南瓜派对狂欢夜'); + expect(created.sortOrder).toBe(0); + + // 验证置顶生效:getActiveBanners 第一项即为新轮播图 + const activeList = bannerService.getActiveBanners(); + expect(activeList.length).toBe(4); + expect(activeList[0].id).toBe(4); + }); + + it('创建或编辑轮播图时,空标题应被拒绝拦截', () => { + expect(() => { + bannerService.createBanner({ + title: ' ', + subtitle: '空标题测试' + }); + }).toThrow('轮播图标题不能为空'); + }); + + it('创建或编辑轮播图时,涉嫌违规敏感词应触发微信内容安全拦截', () => { + expect(() => { + bannerService.createBanner({ + title: '私下转账免手续费活动返利', + subtitle: '欺诈引流测试' + }); + }).toThrow(); + + expect(() => { + bannerService.createBanner({ + title: '美女上门特殊服务招嫖', + subtitle: '违规低俗' + }); + }).toThrow(); + }); + + it('支持快速切换上下线状态并在移动端实时动态显隐', () => { + // 下线 ID 为 1 的轮播图 + const offlineItem = bannerService.toggleStatus(1, 0); + expect(offlineItem.status).toBe(0); + + // 移动端列表中已不再包含 ID 为 1 + const activeList = bannerService.getActiveBanners(); + expect(activeList.some((b) => b.id === 1)).toBe(false); + + // 后台全量列表中仍保留 + const allList = bannerService.getAllBanners(); + expect(allList.some((b) => b.id === 1)).toBe(true); + + // 重新上线 + const onlineItem = bannerService.toggleStatus(1, 1); + expect(onlineItem.status).toBe(1); + expect(bannerService.getActiveBanners().some((b) => b.id === 1)).toBe(true); + }); + + it('非法状态值切换应被拦截', () => { + expect(() => { + bannerService.toggleStatus(1, 99 as any); + }).toThrow('状态参数必须为 0 或 1'); + }); + + it('支持修改轮播图配置与排序', () => { + const updated = bannerService.updateBanner(2, { + title: '工体纯KTV超人气嗨唱局', + subtitle: '豪华大包 · 专属金牌麦霸', + sortOrder: 10 + }); + + expect(updated.title).toBe('工体纯KTV超人气嗨唱局'); + expect(updated.sortOrder).toBe(10); + }); + + it('支持删除轮播图,删除不存在项时抛出异常', () => { + const ok = bannerService.deleteBanner(3); + expect(ok).toBe(true); + expect(bannerService.getAllBanners().some((b) => b.id === 3)).toBe(false); + + expect(() => { + bannerService.deleteBanner(999); + }).toThrow('轮播图不存在或已被删除'); + }); + }); + + describe('2. 服务端数据库与 API 架构持久化门禁校验', () => { + it('MySQL 数据库 schema 必须包含 banners 表定义与默认种子数据', () => { + const mysqlContent = fs.readFileSync(mysqlPath, 'utf-8'); + expect(mysqlContent).toContain('CREATE TABLE IF NOT EXISTS banners'); + expect(mysqlContent).toContain('sort_order INT DEFAULT 0'); + expect(mysqlContent).toContain('status TINYINT DEFAULT 1'); + expect(mysqlContent).toContain('INSERT INTO banners'); + }); + + it('Repository 数据仓储层必须实现完整的 Banner CRUD 接口', () => { + const repoContent = fs.readFileSync(repoPath, 'utf-8'); + expect(repoContent).toContain('async getBanners(): Promise'); + expect(repoContent).toContain('async getAdminBanners(): Promise'); + expect(repoContent).toContain('async createBanner('); + expect(repoContent).toContain('async updateBanner('); + expect(repoContent).toContain('async updateBannerStatus('); + expect(repoContent).toContain('async deleteBanner('); + // 必须带状态过滤与排序 + expect(repoContent).toContain('WHERE status = 1'); + expect(repoContent).toContain('ORDER BY sort_order ASC'); + }); + + it('server/src/index.ts 必须注册完整的 Banner 运维 API 端点并配置微信内容安全检查', () => { + const indexContent = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(indexContent).toContain("app.get('/api/banners'"); + expect(indexContent).toContain("app.get('/api/admin/banners'"); + expect(indexContent).toContain("app.post('/api/admin/banners'"); + expect(indexContent).toContain("app.put('/api/admin/banners/:id'"); + expect(indexContent).toContain("app.post('/api/admin/banners/:id/status'"); + expect(indexContent).toContain("app.delete('/api/admin/banners/:id'"); + expect(indexContent).toContain('contentSecurityService.checkText('); + }); + + it('小程序前端 index.vue 必须通过 getBanners 动态接入服务端轮播图数据', () => { + const mobileContent = fs.readFileSync(mobileIndexPath, 'utf-8'); + expect(mobileContent).toContain('v-for="banner in banners"'); + expect(mobileContent).toContain('fetchLiveBanners()'); + expect(mobileContent).toContain('getBanners()'); + expect(mobileContent).toContain('goToActivity(banner)'); + }); + }); + + describe('3. 规范门禁 (AGENTS.md & 零 Emoji 规则)', () => { + 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/banner/banner.service.ts'), 'utf-8'); + const testContent = fs.readFileSync(__filename, 'utf-8'); + expect(emojiRegex.test(serviceContent)).toBe(false); + expect(emojiRegex.test(testContent)).toBe(false); + }); + }); +});