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> {