diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md index 815979a..5247b00 100644 --- a/docs/部署验收与1分钱支付闭环.md +++ b/docs/部署验收与1分钱支付闭环.md @@ -150,6 +150,11 @@ MYSQL_DATABASE=peiwan - `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。 6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。 +### 站内消息通知中心(已内置,无需额外配置) +- 关键节点(订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理)都会写入 `user_notifications`; +- 小程序「个人中心 → 消息通知」可查看历史与未读数(`GET /api/notifications`、`POST /api/notifications/read`); +- 即使未授权微信订阅消息,用户仍能看到完整通知历史,作为订阅消息的兜底渠道。 + ### 限流与错误信息(已内置,无需额外配置) - **全局限流**:`/api` 每**用户**(未登录按 IP)1200 次/分钟;**写接口单独限流**:发帖 10/分、评论 20/分、组局申请 20/分、举报 5/分、上传 30/分、下单 20/分、支付下单 30/分、充值/提现 10/分、浏览 1 次/3 秒(同 IP+动态)。超限返回 HTTP `429` + `Retry-After`。 - ⚠️ **反向代理务必透传真实客户端 IP**,否则限流会把所有用户并成一个 IP 桶: diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index a65e5a4..62dba1b 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -1212,6 +1212,30 @@ export async function getSubscribeTemplates() { } } +/** + * 获取站内消息通知列表与未读数 + */ +export async function getNotifications(userId, limit = 50) { + const uid = normalizeUserId(userId); + try { + const res = await http.get(`/notifications?userId=${uid}&limit=${Number(limit) || 50}`); + return res || { list: [], unread: 0 }; + } catch (err) { + throw err; + } +} + +/** + * 标记站内消息为已读(不传 ids 表示全部已读) + */ +export async function markNotificationsRead(userId, ids) { + const uid = normalizeUserId(userId); + const payload = { userId: uid }; + if (Array.isArray(ids) && ids.length > 0) payload.ids = ids; + const res = await http.post('/notifications/read', payload); + return res; +} + /** * 保存用户隐私与通知订阅设置 (同步落盘服务端与本地存储) */ diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json index f66c621..03c18c9 100644 --- a/pwuniapp/pages.json +++ b/pwuniapp/pages.json @@ -201,6 +201,16 @@ "backgroundColor": "#0A0F1D" } }, + { + "path": "pages/user/notifications", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "消息通知", + "navigationBarBackgroundColor": "#0A0F1D", + "navigationBarTextStyle": "white", + "backgroundColor": "#0A0F1D" + } + }, { "path": "pages/activity/create", "style": { diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index 44197ff..536d8d9 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -149,6 +149,12 @@ 官方在线客服 + + + 消息通知 + {{ noticeUnread }}条未读 + + 平台消息与通知 @@ -351,7 +357,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, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode } from '@/common/api.js'; +import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode, getNotifications } from '@/common/api.js'; import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscribe.js'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; @@ -384,6 +390,7 @@ const openMyReportsModal = () => { // 系统与隐私设置 const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1'); const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') === '1'); +const noticeUnread = ref(0); const cacheSizeText = ref('1.25 MB'); // 分销推广管理 @@ -643,6 +650,7 @@ onShow(() => { loadPlatformConfigs(); privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1'; + loadNoticeUnread(); if (isLoggedIn.value) { loadFavorites(); loadMyReports(); @@ -1214,6 +1222,26 @@ const openNotice = () => { }); }; +const openNotifications = () => { + uni.navigateTo({ + url: '/pages/user/notifications', + fail: () => { + uni.showToast({ title: '页面打开失败,请稍后重试', icon: 'none' }); + } + }); +}; + +const loadNoticeUnread = async () => { + const uid = uni.getStorageSync('userId') || 0; + if (!uid) return; + try { + const res = await getNotifications(uid, 1); + noticeUnread.value = Number(res?.unread || 0); + } catch (_) { + // 未登录或网络异常时保持原值 + } +}; + const openFaq = () => { uni.navigateTo({ url: '/pages/user/help-faq', diff --git a/pwuniapp/pages/user/notifications.vue b/pwuniapp/pages/user/notifications.vue new file mode 100644 index 0000000..7d7a6b3 --- /dev/null +++ b/pwuniapp/pages/user/notifications.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 462e842..e816016 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -645,6 +645,21 @@ export async function initDatabase(): Promise { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // 站内消息通知中心:订单/提现/报名/退款/仲裁等关键动作落库,供小程序「消息中心」查看 + await conn.query(` + CREATE TABLE IF NOT EXISTS user_notifications ( + id VARCHAR(64) PRIMARY KEY, + user_id INT NOT NULL, + type VARCHAR(32) NOT NULL DEFAULT 'SYSTEM', + title VARCHAR(128) NOT NULL, + content VARCHAR(512) NOT NULL DEFAULT '', + link VARCHAR(255) NOT NULL DEFAULT '', + is_read TINYINT NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_notif_user_read (user_id, is_read, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + // 慢查询优化:为高频查询列补充索引(幂等:已存在则忽略;表不存在也忽略) // 注意:必须在所有建表语句之后执行,确保目标表已存在。 const indexDefs: Array<[string, string, string]> = [ diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 21a203a..239b820 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -3125,6 +3125,76 @@ export class MysqlRepository { return rows; } + // ===== 站内消息通知中心 ===== + async createUserNotification(data: { + userId: number; + type?: string; + title: string; + content?: string; + link?: string; + }): Promise { + const uid = Number(data.userId) || 0; + if (uid <= 0 || !data.title) return null; + const id = `ntf_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; + await pool.query( + `INSERT INTO user_notifications (id, user_id, type, title, content, link, is_read, created_at) + VALUES (?, ?, ?, ?, ?, ?, 0, NOW())`, + [ + id, + uid, + String(data.type || 'SYSTEM').slice(0, 32), + String(data.title).slice(0, 120), + String(data.content || '').slice(0, 500), + String(data.link || '').slice(0, 250), + ] + ); + return { id }; + } + + async getUserNotifications(userId: number, limit = 50): Promise { + const uid = Number(userId) || 0; + if (uid <= 0) return []; + const take = Math.min(200, Math.max(1, Number(limit) || 50)); + const [rows]: any = await pool.query( + `SELECT id, type, title, content, link, is_read as isRead, created_at as createdAt + FROM user_notifications + WHERE user_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ${take}`, + [uid] + ); + return rows; + } + + async getUserUnreadNotificationCount(userId: number): Promise { + const uid = Number(userId) || 0; + if (uid <= 0) return 0; + const [rows]: any = await pool.query( + 'SELECT COUNT(*) as c FROM user_notifications WHERE user_id = ? AND is_read = 0', + [uid] + ); + return Number((rows && rows[0] && rows[0].c) || 0); + } + + async markUserNotificationsRead(userId: number, ids?: string[]): Promise { + const uid = Number(userId) || 0; + if (uid <= 0) return 0; + if (Array.isArray(ids) && ids.length > 0) { + const safe = ids.map((x) => String(x)).slice(0, 200); + const ph = safe.map(() => '?').join(','); + const [r]: any = await pool.query( + `UPDATE user_notifications SET is_read = 1 WHERE user_id = ? AND id IN (${ph})`, + [uid, ...safe] + ); + return Number((r && r.affectedRows) || 0); + } + const [r]: any = await pool.query( + 'UPDATE user_notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0', + [uid] + ); + return Number((r && r.affectedRows) || 0); + } + async getWithdrawals(): Promise { const [rows]: any = await pool.query(` SELECT @@ -3133,8 +3203,7 @@ export class MysqlRepository { status, apply_time as applyTime FROM withdrawals ORDER BY apply_time DESC - `); - return rows; + `); return rows; } async reviewWithdrawal(id: string, action: 'approve' | 'reject', rejectReason?: string): Promise { diff --git a/server/src/index.ts b/server/src/index.ts index 4d42d4e..0d49692 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -512,7 +512,7 @@ app.all('/api/user/info', (_req: Request, res: Response) => { data: { name: '超级管理员', avatar: 'data:image/svg+xml;utf8,AD', - email: 'admin@peiwan.com', + email: '', job: 'super_admin', jobName: '全权运营总监', organization: 'Platform', @@ -520,8 +520,8 @@ app.all('/api/user/info', (_req: Request, res: Response) => { location: 'beijing', locationName: '北京', introduction: '具备平台最高运营、风控与财务审核权限', - personalWebsite: 'https://peiww.gynhc.com', - phone: '188****8888', + personalWebsite: '', + phone: '', registrationDate: '2026-09-01 10:00:00', accountId: '10001', certification: 1, @@ -547,56 +547,32 @@ app.all('/api/user/menu', (_req: Request, res: Response) => { }); // 1.2 个人中心与管理员辅助接口 (用户设置与用户信息页) +// 平台未接入企业实名认证:返回"未认证"空态,不再返回伪造的企业信息 app.all('/api/user/certification', (_req: Request, res: Response) => { res.json({ code: 20000, data: { enterpriseInfo: { accountType: 1, - status: 1, - time: '2026-09-01 10:00:00', - legalPerson: '平台管理员', - certificateType: '统一社会信用代码', - authenticationNumber: '91110108MA00000000', - enterpriseName: '同城生活陪伴运营服务中心', - enterpriseCertificateType: '营业执照', - organizationCode: 'MA000000-0', + status: 0, + time: '', + legalPerson: '', + certificateType: '', + authenticationNumber: '', + enterpriseName: '', + enterpriseCertificateType: '', + organizationCode: '', }, - record: [ - { - certificationType: 1, - certificationContent: '企业实名认证及运营资质核准', - status: 1, - time: '2026-09-01 10:00:00', - } - ] + record: [] } }); }); +// 平台未接入多项目管理:返回空数组,不再返回伪造项目与成员 app.all('/api/user/my-project/list', (_req: Request, res: Response) => { res.json({ code: 20000, - data: [ - { - id: 1, - name: '同城线下生活陪伴运营大盘', - description: '涵盖线下陪吃、陪喝、陪唱与线上游戏陪伴的综合履约与合规监管系统。', - peopleNumber: 12, - contributors: [ - { name: '超级管理员', email: 'admin@peiwan.com', avatar: 'data:image/svg+xml;utf8,AD' } - ] - }, - { - id: 2, - name: '同城公共商圈合规与风控拦截', - description: '高频违规场所自动识别与阻断引擎,保障线下组局公开与人身安全。', - peopleNumber: 6, - contributors: [ - { name: '风控专员', email: 'sec@peiwan.com', avatar: 'data:image/svg+xml;utf8,FK' } - ] - } - ] + data: [] }); }); @@ -756,6 +732,43 @@ app.all('/api/chat/list', (_req: Request, res: Response) => { res.json({ code: 20000, data: [] }); }); +// ===== 站内消息通知中心(用户端真实数据源)===== +// 关键动作(支付/接单/拒单/完成/提现到账/报名/退款/仲裁)都会写入 user_notifications, +// 即使未授权微信订阅消息,用户仍可在小程序「消息中心」看到完整通知历史。 +app.get('/api/notifications', async (req: Request, res: Response) => { + try { + const uid = resolveSecureUserId( + req, + Number(req.query.userId || req.headers['x-user-id'] || 0), + '查看消息通知' + ); + const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50); + const unread = await repo.getUserUnreadNotificationCount(uid); + res.json({ code: 20000, data: { list, unread } }); + } catch (err: any) { + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); + } +}); + +app.post('/api/notifications/read', async (req: Request, res: Response) => { + try { + const uid = resolveSecureUserId( + req, + Number(req.body?.userId || req.headers['x-user-id'] || 0), + '标记消息已读' + ); + const ids = Array.isArray(req.body?.ids) ? req.body.ids : undefined; + const affected = await repo.markUserNotificationsRead(uid, ids); + res.json({ code: 20000, data: { affected }, message: '消息已标记为已读' }); + } catch (err: any) { + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); + } +}); + // 2. 运营后台统计大盘接口 (真实读取 MySQL) app.get('/api/dashboard/stats', async (_req: Request, res: Response) => { try { @@ -879,6 +892,7 @@ app.post('/api/activities/:id/join', async (req: Request, res: Response) => { }); try { const act = await repo.getActivityById(actId); + pushUserNotification(uid, 'ACTIVITY', '报名成功', `你已成功报名「${String(act?.title || '同城组局活动')}」,开始时间 ${toCnTimeOrNow(act?.startTime)}。`, '/pages/activity/my-ticket'); void notifyBySubscribe('activityJoin', { userId: uid, page: '/pages/activity/my-ticket', @@ -1338,6 +1352,7 @@ app.post('/api/orders/:id/claim', async (req: Request, res: Response) => { await assertUserNotBanned(operatorId, '接单'); } const order = await repo.claimOrder(orderId, { ...(req.body || {}), expertId: operatorId }); + pushUserNotification(Number(order?.buyer_id || order?.buyerId || 0), 'ORDER_STATUS', '大神已接单', `订单 ${order?.order_no || orderId} 已被大神接单,服务即将开始。`, '/pages/order/list'); res.json({ code: 20000, data: order, message: '抢单成功,服务已锁定为履约中' }); } catch (err: any) { const statusCode = err.status || 400; @@ -1984,6 +1999,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { } const order = await repo.updateOrderStatus(orderId, '履约中'); const buyer = Number(own.buyerId || 0); + pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入履约中${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list'); void notifyBySubscribe('orderStatus', { userId: buyer, page: '/pages/order/list', @@ -2010,6 +2026,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { } const order = await repo.updateOrderStatus(orderId, requested); const buyer = Number(own.buyerId || 0); + pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入${requested}${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list'); void notifyBySubscribe('orderStatus', { userId: buyer, page: '/pages/order/list', @@ -2442,6 +2459,15 @@ function toCnTimeOrNow(value?: string): string { return formatCnTime(d); } +// 站内消息落库(fire-and-forget,绝不影响主业务;与微信订阅消息并行) +function pushUserNotification(userId: number, type: string, title: string, content: string, link = ''): void { + const uid = Number(userId) || 0; + if (uid <= 0) return; + void repo + .createUserNotification({ userId: uid, type, title, content, link }) + .catch((e: any) => logServerError('pushUserNotification', e)); +} + async function notifyBySubscribe( templateKey: 'orderStatus' | 'activityJoin' | 'withdrawSuccess' | 'verifyCode', opts: { userId: number; page?: string; data: Record } @@ -2928,6 +2954,7 @@ app.post('/api/pay/wechat/transfer-notify', async (req: Request, res: Response) if (outBillNo && state) { const r: any = await repo.finalizeWithdrawalTransfer(String(outBillNo), String(state)); if (r && r.status === '已打款' && r.userId) { + pushUserNotification(Number(r.userId), 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(r.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records'); void notifyBySubscribe('withdrawSuccess', { userId: Number(r.userId), page: '/pages/user/withdraw-records', @@ -3005,6 +3032,7 @@ app.post('/api/orders/:id/reject', async (req: Request, res: Response) => { return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可拒单' }); } const order = await repo.rejectOrder(orderId, req.body); + pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '订单已被拒单', `订单 ${orderId} 已被大神拒单,款项将原路退回。`, '/pages/order/list'); res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' }); } catch (err: any) { const statusCode = err.status || 400; @@ -3026,6 +3054,7 @@ app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) = return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可申请完成服务' }); } const order = await repo.applyCompleteOrder(orderId); + pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '大神申请完成服务', `订单 ${orderId} 大神已申请完成服务,请及时确认验收(48 小时未处理将自动确认)。`, '/pages/order/list'); res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' }); } catch (err: any) { const statusCode = err.status || 400; @@ -3047,6 +3076,11 @@ app.post('/api/orders/:id/complete', async (req: Request, res: Response) => { return res.status(403).json({ code: 40300, message: '仅交易双方或平台管理员可完成该订单' }); } const order = await repo.updateOrderStatus(orderId, '已完成'); + pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '订单已完成', `订单 ${orderId} 已完成,感谢使用,欢迎评价。`, '/pages/order/list'); + const companionIds: number[] = Array.isArray(own?.companionIds) ? own.companionIds : []; + companionIds.forEach((cid) => + pushUserNotification(Number(cid) || 0, 'ORDER_STATUS', '订单已完成结算', `订单 ${orderId} 已完成,佣金将按平台规则结算入账。`, '/pages/expert/workspace') + ); res.json({ code: 20000, data: order, message: '订单已确认完成' }); } catch (err: any) { const statusCode = err.status || 400; @@ -3086,6 +3120,7 @@ app.post('/api/orders/:id/refund', async (req: Request, res: Response) => { } } const order = await repo.applyOrderRefund(orderId, { reason, description, evidence }); + pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '退款申诉已受理', `订单 ${orderId} 的退款申诉已受理,进入争议仲裁流程。`, '/pages/user/reports'); res.json({ code: 20000, data: order, message: '退款申诉已受理,进入争议仲裁流程' }); } catch (err: any) { const statusCode = err.status || 400; @@ -3266,6 +3301,7 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon const billNo = String(bill.out_bill_no || outBillNo); if (state === 'SUCCESS') { const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS'); + pushUserNotification(Number(wd.companion_id) || 0, 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records'); void notifyBySubscribe('withdrawSuccess', { userId: Number(wd.companion_id) || 0, page: '/pages/user/withdraw-records', diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index aabf71f..53aceff 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 const raw = fs.readFileSync(pagesJsonPath, 'utf-8'); const config = JSON.parse(raw); expect(Array.isArray(config.pages)).toBe(true); - expect(config.pages.length).toBe(26); + expect(config.pages.length).toBe(27); for (const pageItem of config.pages) { const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`); diff --git a/server/tests/notification-center.spec.ts b/server/tests/notification-center.spec.ts new file mode 100644 index 0000000..54ebde2 --- /dev/null +++ b/server/tests/notification-center.spec.ts @@ -0,0 +1,51 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第七十六批:站内消息通知中心(用户端真实数据源)+ 清理后台假数据。 + */ +describe('第七十六批消息通知中心闸门', () => { + const root = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8'); + const repo = fs.readFileSync(path.join(root, 'server/src/db/repository.ts'), 'utf-8'); + const mysql = fs.readFileSync(path.join(root, 'server/src/db/mysql.ts'), 'utf-8'); + const api = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8'); + const userPage = fs.readFileSync(path.join(root, 'pwuniapp/pages/user/index.vue'), 'utf-8'); + const pagesJson = fs.readFileSync(path.join(root, 'pwuniapp/pages.json'), 'utf-8'); + + it('数据库与仓储具备通知能力', () => { + expect(mysql).toContain('CREATE TABLE IF NOT EXISTS user_notifications'); + expect(repo).toContain('async createUserNotification('); + expect(repo).toContain('async getUserNotifications('); + expect(repo).toContain('async getUserUnreadNotificationCount('); + expect(repo).toContain('async markUserNotificationsRead('); + }); + + it('提供用户端通知接口', () => { + expect(indexCode).toContain("app.get('/api/notifications'"); + expect(indexCode).toContain("app.post('/api/notifications/read'"); + expect(indexCode).toContain('function pushUserNotification('); + }); + + it('关键动作写入站内通知(订单/提现/报名/退款)', () => { + expect(indexCode).toContain("pushUserNotification(buyer, 'ORDER_STATUS'"); + expect(indexCode).toContain("'WITHDRAW', '提现已到账'"); + expect(indexCode).toContain("'ACTIVITY', '报名成功'"); + expect(indexCode).toContain("'ORDER_REFUND'"); + }); + + it('小程序具备消息中心页面与入口', () => { + expect(pagesJson).toContain('pages/user/notifications'); + expect(api).toContain('export async function getNotifications('); + expect(api).toContain('export async function markNotificationsRead('); + expect(userPage).toContain('openNotifications'); + expect(userPage).toContain('noticeUnread'); + }); + + it('后台伪造数据已清理', () => { + expect(indexCode).not.toContain('同城生活陪伴运营服务中心'); + expect(indexCode).not.toContain('91110108MA00000000'); + expect(indexCode).not.toContain('admin@peiwan.com'); + }); +});