From ca7f849b14133cba5607b4bb9dda7f7974e6a05a Mon Sep 17 00:00:00 2001
From: xcbyy <2578898740@qq.com>
Date: Mon, 19 Oct 2026 14:31:11 +0800
Subject: [PATCH] =?UTF-8?q?feat(notify):=20=E6=96=B0=E5=A2=9E=E7=AB=99?=
=?UTF-8?q?=E5=86=85=E6=B6=88=E6=81=AF=E9=80=9A=E7=9F=A5=E4=B8=AD=E5=BF=83?=
=?UTF-8?q?=EF=BC=88=E8=AE=A2=E9=98=85=E6=B6=88=E6=81=AF=E5=85=9C=E5=BA=95?=
=?UTF-8?q?=EF=BC=89+=20=E6=B8=85=E7=90=86=E5=90=8E=E5=8F=B0=E4=BC=AA?=
=?UTF-8?q?=E9=80=A0=E6=95=B0=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新表 user_notifications + 仓储方法(创建/列表/未读数/标记已读)
- 用户接口 GET /api/notifications、POST /api/notifications/read(生产需登录)
- 关键节点落库:订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理
- 小程序新增「消息通知」页(pages/user/notifications) + 个人中心入口与未读角标;api.js 增加 getNotifications/markNotificationsRead
- 清理后台伪造数据:/api/user/certification 改空态、/api/user/my-project/list 改空数组、/api/user/info 去掉编造邮箱/电话/网址
- 更新 pages 数量断言(26→27);新增回归护栏 notification-center.spec.ts(92 套件/658 用例全绿);冒烟 9/9
---
docs/部署验收与1分钱支付闭环.md | 5 +
pwuniapp/common/api.js | 24 ++
pwuniapp/pages.json | 10 +
pwuniapp/pages/user/index.vue | 30 ++-
pwuniapp/pages/user/notifications.vue | 317 +++++++++++++++++++++++
server/src/db/mysql.ts | 15 ++
server/src/db/repository.ts | 73 +++++-
server/src/index.ts | 114 +++++---
server/tests/content-security.spec.ts | 2 +-
server/tests/notification-center.spec.ts | 51 ++++
10 files changed, 598 insertions(+), 43 deletions(-)
create mode 100644 pwuniapp/pages/user/notifications.vue
create mode 100644 server/tests/notification-center.spec.ts
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 @@
+
+
+
+
+
+
+
+
+
+ 消息通知
+
+
+ 全部已读
+
+
+
+
+
+
+
+
+ 通知中心
+ {{ unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知,订单/提现/报名进展都会在这里留痕' }}
+
+
+
+ 加载中…
+
+
+
+ 暂无消息
+ 下单、支付、接单、提现到账等节点,平台都会在此提醒你
+
+
+
+
+
+
+
+ {{ item.title }}
+ {{ formatTime(item.createdAt) }}
+
+ {{ item.content }}
+
+
+
+
+
+
+
+
+
+
+
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,',
- 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,' }
- ]
- },
- {
- id: 2,
- name: '同城公共商圈合规与风控拦截',
- description: '高频违规场所自动识别与阻断引擎,保障线下组局公开与人身安全。',
- peopleNumber: 6,
- contributors: [
- { name: '风控专员', email: 'sec@peiwan.com', avatar: 'data:image/svg+xml;utf8,' }
- ]
- }
- ]
+ 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');
+ });
+});