feat(notify): 新增站内消息通知中心(订阅消息兜底)+ 清理后台伪造数据

- 新表 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
这个提交包含在:
xcbyy
2026-10-19 14:31:11 +08:00
父节点 12bbf4fc6b
当前提交 ca7f849b14
共有 10 个文件被更改,包括 598 次插入 和 43 次删除

查看文件

@@ -645,6 +645,21 @@ export async function initDatabase(): Promise<void> {
) 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]> = [

查看文件

@@ -3125,6 +3125,76 @@ export class MysqlRepository {
return rows;
}
// ===== 站内消息通知中心 =====
async createUserNotification(data: {
userId: number;
type?: string;
title: string;
content?: string;
link?: string;
}): Promise<any> {
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<any[]> {
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<number> {
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<number> {
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<any[]> {
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<any> {

查看文件

@@ -512,7 +512,7 @@ app.all('/api/user/info', (_req: Request, res: Response) => {
data: {
name: '超级管理员',
avatar: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%23165DFF"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">AD</text></svg>',
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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%23165DFF"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">AD</text></svg>' }
]
},
{
id: 2,
name: '同城公共商圈合规与风控拦截',
description: '高频违规场所自动识别与阻断引擎,保障线下组局公开与人身安全。',
peopleNumber: 6,
contributors: [
{ name: '风控专员', email: 'sec@peiwan.com', avatar: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%2300B42A"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">FK</text></svg>' }
]
}
]
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<string, string | number> }
@@ -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',

查看文件

@@ -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`);

查看文件

@@ -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');
});
});