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 次删除

查看文件

@@ -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 桶:

查看文件

@@ -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;
}
/**
* 保存用户隐私与通知订阅设置 (同步落盘服务端与本地存储)
*/

查看文件

@@ -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": {

查看文件

@@ -149,6 +149,12 @@
<text class="row-label">官方在线客服</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openNotifications">
<image class="row-svg" :src="ICONS.chat" mode="aspectFit" />
<text class="row-label">消息通知</text>
<text class="row-sub-tag" v-if="noticeUnread > 0">{{ noticeUnread }}条未读</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openNotice">
<image class="row-svg" :src="ICONS.bell" mode="aspectFit" />
<text class="row-label">平台消息与通知</text>
@@ -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',

查看文件

@@ -0,0 +1,317 @@
<template>
<view class="notif-page" :class="['theme-' + currentThemeKey]" :style="navStyle">
<!-- 顶部沉浸式自定义导航栏 -->
<view class="custom-nav-bar">
<view class="nav-content">
<view class="btn-nav-back btn-active" @click="handleBack">
<image class="nav-back-icon" :src="ICONS.chevronLeft" mode="aspectFit" />
</view>
<view class="nav-title-wrap">
<text class="nav-title">消息通知</text>
</view>
<view class="nav-action btn-active" v-if="unreadCount > 0" @click="handleReadAll">
<text class="nav-action-text">全部已读</text>
</view>
<view class="nav-capsule-placeholder" v-else></view>
</view>
</view>
<scroll-view
scroll-y
class="notif-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresh"
>
<view class="notif-body">
<view class="notif-summary">
<text class="summary-title">通知中心</text>
<text class="summary-sub">{{ unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知,订单/提现/报名进展都会在这里留痕' }}</text>
</view>
<view v-if="isLoading" class="notif-empty">
<text class="empty-text">加载中…</text>
</view>
<view v-else-if="list.length === 0" class="notif-empty">
<text class="empty-title">暂无消息</text>
<text class="empty-text">下单、支付、接单、提现到账等节点,平台都会在此提醒你</text>
</view>
<view v-else class="notif-list">
<view
v-for="item in list"
:key="item.id"
class="notif-card btn-active"
:class="{ 'is-unread': !item.isRead }"
@click="handleOpen(item)"
>
<view class="notif-dot" v-if="!item.isRead"></view>
<view class="notif-main">
<view class="notif-head">
<text class="notif-title">{{ item.title }}</text>
<text class="notif-time">{{ formatTime(item.createdAt) }}</text>
</view>
<text class="notif-content">{{ item.content }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { ICONS } from '@/common/icons.js';
import { getCurrentThemeKey } from '@/common/theme.js';
import { getNavBarInfo } from '@/common/nav.js';
import { getNotifications, markNotificationsRead } from '@/common/api.js';
const currentThemeKey = ref(getCurrentThemeKey());
const navInfo = ref(getNavBarInfo());
const isLoading = ref(true);
const isRefreshing = ref(false);
const list = ref([]);
const unreadCount = ref(0);
const navStyle = computed(() => ({
'--safe-header-gap': `${navInfo.value.totalHeaderHeight}px`,
'--capsule-right-space': `${navInfo.value.capsuleRightSpace}px`,
'--capsule-height': `${navInfo.value.capsuleHeight}px`
}));
const currentUserId = () => uni.getStorageSync('userId') || uni.getStorageSync('uid') || 0;
const formatTime = (v) => {
if (!v) return '';
const s = String(v).replace('T', ' ').replace('Z', '');
return s.length >= 16 ? s.slice(0, 16) : s;
};
const loadData = async () => {
const uid = currentUserId();
if (!uid) {
isLoading.value = false;
return;
}
try {
const res = await getNotifications(uid, 50);
list.value = Array.isArray(res?.list) ? res.list : [];
unreadCount.value = Number(res?.unread || 0);
} catch (_) {
list.value = [];
unreadCount.value = 0;
} finally {
isLoading.value = false;
isRefreshing.value = false;
}
};
const onRefresh = () => {
isRefreshing.value = true;
loadData();
};
const handleReadAll = async () => {
const uid = currentUserId();
if (!uid) return;
try {
await markNotificationsRead(uid);
list.value = list.value.map((x) => ({ ...x, isRead: 1 }));
unreadCount.value = 0;
uni.showToast({ title: '已全部标记为已读', icon: 'none' });
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' });
}
};
const handleOpen = async (item) => {
const uid = currentUserId();
if (!item.isRead && uid) {
try {
await markNotificationsRead(uid, [item.id]);
item.isRead = 1;
unreadCount.value = Math.max(0, unreadCount.value - 1);
} catch (_) {}
}
const link = String(item.link || '');
if (!link) return;
const tabPages = [
'/pages/index/index',
'/pages/activity/list',
'/pages/order/create',
'/pages/community/index',
'/pages/user/index'
];
if (tabPages.includes(link)) {
uni.switchTab({ url: link, fail: () => {} });
} else {
uni.navigateTo({ url: link, fail: () => {} });
}
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages && pages.length > 1) {
uni.navigateBack();
} else {
uni.switchTab({ url: '/pages/user/index' });
}
};
onShow(() => {
navInfo.value = getNavBarInfo();
currentThemeKey.value = getCurrentThemeKey();
loadData();
});
</script>
<style scoped>
.notif-page {
min-height: 100vh;
background: #0a0f1d;
}
.custom-nav-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(10, 15, 29, 0.92);
padding-top: var(--safe-header-gap, 0);
}
.nav-content {
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
}
.btn-nav-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-back-icon {
width: 18px;
height: 18px;
}
.nav-title-wrap {
flex: 1;
text-align: center;
}
.nav-title {
color: #fff;
font-size: 17px;
font-weight: 600;
}
.nav-action {
min-width: 56px;
text-align: right;
}
.nav-action-text {
color: #8b9cff;
font-size: 13px;
}
.nav-capsule-placeholder {
width: 56px;
}
.notif-scroll {
height: calc(100vh - 44px - var(--safe-header-gap, 0px));
}
.notif-body {
padding: 12px 16px 40px;
}
.notif-summary {
padding: 16px;
border-radius: 16px;
background: linear-gradient(135deg, #1b2340 0%, #141a30 100%);
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.summary-title {
color: #fff;
font-size: 16px;
font-weight: 700;
}
.summary-sub {
color: #9aa7c7;
font-size: 12px;
line-height: 1.5;
}
.notif-empty {
padding: 48px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.empty-title {
color: #e6eaf5;
font-size: 15px;
font-weight: 600;
}
.empty-text {
color: #7d89a8;
font-size: 12px;
text-align: center;
line-height: 1.6;
}
.notif-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.notif-card {
position: relative;
padding: 14px 14px 14px 18px;
border-radius: 14px;
background: #141a30;
display: flex;
align-items: flex-start;
gap: 10px;
}
.notif-card.is-unread {
background: #182143;
}
.notif-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff5f57;
margin-top: 6px;
}
.notif-main {
flex: 1;
min-width: 0;
}
.notif-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.notif-title {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.notif-time {
color: #6d7997;
font-size: 11px;
flex-shrink: 0;
}
.notif-content {
display: block;
margin-top: 6px;
color: #a8b3d1;
font-size: 12px;
line-height: 1.6;
}
.btn-active:active {
opacity: 0.75;
}
</style>

查看文件

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